diff --git a/doc/reference/modules/batch.xml b/doc/reference/modules/batch.xml index d12935744a3..e056775c6a5 100644 --- a/doc/reference/modules/batch.xml +++ b/doc/reference/modules/batch.xml @@ -6,14 +6,16 @@ look like this: - + This would fall over with an OutOfMemoryException somewhere @@ -56,21 +58,24 @@ session.Close();]]> the first-level cache. - + + tx.Commit(); +}]]> @@ -90,20 +95,21 @@ session.Close();]]> to data aliasing effects, due to the lack of a first-level cache. A stateless session is a lower-level abstraction, much closer to the underlying ADO. - -(); -while ( customers.MoveNext() ) { - Customer customer = customers.Current; - customer.updateStuff(...); - session.Update(customer); -} - -tx.Commit(); -session.Close();]]> + + (); + while (customers.MoveNext()) + { + Customer customer = customers.Current; + customer.updateStuff(...); + session.Update(customer); + } + + tx.Commit(); +}]]> Note that in this code example, the Customer instances returned @@ -176,17 +182,17 @@ session.Close();]]> IQuery.ExecuteUpdate() method: - + tx.Commit(); +}]]> HQL UPDATE statements, by default do not effect the @@ -198,15 +204,17 @@ session.Close();]]> This is achieved by adding the VERSIONED keyword after the UPDATE keyword. - + tx.Commit(); +}]]> Note that custom version types (NHibernate.Usertype.IUserVersionType) @@ -218,16 +226,16 @@ session.Close();]]> method: - + tx.Commit(); +}]]> The int value returned by the IQuery.ExecuteUpdate() @@ -302,14 +310,14 @@ session.Close();]]> An example HQL INSERT statement execution: - + tx.Commit(); +}]]> diff --git a/doc/reference/modules/collection_mapping.xml b/doc/reference/modules/collection_mapping.xml index e4ca76d1d13..ece87a83915 100644 --- a/doc/reference/modules/collection_mapping.xml +++ b/doc/reference/modules/collection_mapping.xml @@ -622,12 +622,14 @@ HashedSet hs = (HashedSet) cat.Kittens; //Error!]]> However, if the application tries something like this: - diff --git a/doc/reference/modules/configuration.xml b/doc/reference/modules/configuration.xml index f0fd4603f24..612f7dab3ae 100644 --- a/doc/reference/modules/configuration.xml +++ b/doc/reference/modules/configuration.xml @@ -600,7 +600,7 @@ ISession session = sessions.OpenSession(conn); The class name of a custom ITransactionFactory implementation, - defaults to the built-in AdoNetWithDistributedTransactionFactory. + defaults to the built-in AdoNetWithSystemTransactionFactory. eg. classname.of.TransactionFactory, assembly diff --git a/doc/reference/modules/manipulating_data.xml b/doc/reference/modules/manipulating_data.xml index 04e8ca0fae5..07249ae43eb 100644 --- a/doc/reference/modules/manipulating_data.xml +++ b/doc/reference/modules/manipulating_data.xml @@ -780,15 +780,18 @@ sess.Lock(pk, LockMode.Upgrade);]]> +using (ITransaction tx = sess.BeginTransaction()) +{ + // allow queries to return stale state + sess.FlushMode = FlushMode.Commit; + Cat izi = (Cat) sess.Load(typeof(Cat), id); + izi.Name = "iznizi"; + // execute some queries.... + sess.Find("from Cat as cat left outer join cat.Kittens kitten"); + // change to izi is not flushed! + ... + tx.Commit(); // flush occurs +}]]> diff --git a/doc/reference/modules/performance.xml b/doc/reference/modules/performance.xml index c785c09fba0..221e0ae56f1 100644 --- a/doc/reference/modules/performance.xml +++ b/doc/reference/modules/performance.xml @@ -132,33 +132,25 @@ fetching for single-valued associations. These defaults make sense for almost all associations in almost all applications. - - - + However, lazy fetching poses one problem that you must be aware of. Access to a lazy association outside of the context of an open NHibernate session will result in an exception. For example: - - +int accessLevel = (int)permissions["accounts"]; // Error!]]> Since the permissions collection was not initialized @@ -262,8 +254,9 @@ int accessLevel = (int) permissions["accounts"]; // Error!]]> - A completely different way to avoid problems with N+1 selects is to use the - second-level cache. + A completely different way to avoid problems with N+1 selects is to use the + second-level cache, or to enable + batch fetching. @@ -310,9 +303,11 @@ int accessLevel = (int) permissions["accounts"]; // Error!]]> instance of DomesticCat: - @@ -320,10 +315,12 @@ if ( cat.IsDomesticCat ) { // hit the db to initialize the prox Secondly, it is possible to break proxy ==. - + However, the situation is not quite as bad as it looks. Even though we now have two references @@ -561,6 +558,12 @@ ICat fritz = (ICat) iter.Current;]]> materialized path might be a better option for read-mostly trees.) + + Note: if you set default_batch_fetch_size + in configuration, NHibernate will configure the batch fetch optimization for lazy fetching + globally. Batch sizes specified at more granular level take precedence. + + diff --git a/doc/reference/modules/query_criteria.xml b/doc/reference/modules/query_criteria.xml index 775eddd97de..adf370bbd4d 100644 --- a/doc/reference/modules/query_criteria.xml +++ b/doc/reference/modules/query_criteria.xml @@ -294,15 +294,16 @@ IList results = session.CreateCriteria(typeof(Cat)) The DetachedCriteria class lets you create a query outside the scope of a session, and then later execute it using some arbitrary ISession. - + + +using (ISession session = ....) +using (ITransaction txn = session.BeginTransaction()) +{ + IList results = query.GetExecutableCriteria(session).SetMaxResults(100).List(); + txn.Commit(); +}]]> A DetachedCriteria may also be used to express a sub-query. ICriterion diff --git a/doc/reference/modules/quickstart.xml b/doc/reference/modules/quickstart.xml index f240d7853b7..304cb20464b 100644 --- a/doc/reference/modules/quickstart.xml +++ b/doc/reference/modules/quickstart.xml @@ -1,5 +1,5 @@ - Quickstart with IIS and Microsoft SQL Server + Quick-start with IIS and Microsoft SQL Server Getting started with NHibernate @@ -368,18 +368,23 @@ namespace QuickStart + session.Save(princess); + tx.Commit(); + } +} +finally +{ + NHibernateHelper.CloseSession(); +}]]> In an ISession, every database operation occurs inside a @@ -406,16 +411,17 @@ NHibernateHelper.CloseSession();]]> which is an easy to learn and powerful object-oriented extension to SQL: - + tx.Commit(); +}]]> NHibernate also offers an object-oriented query by criteria API diff --git a/doc/reference/modules/readonly.xml b/doc/reference/modules/readonly.xml index fc7612d0072..513d62596d1 100644 --- a/doc/reference/modules/readonly.xml +++ b/doc/reference/modules/readonly.xml @@ -319,21 +319,18 @@ before restoring the session default. - -ISession session = factory.OpenSession(); -ITransaction tx = session.BeginTransaction(); - -session.DefaultReadOnly = true; -Contract contract = session.CreateQuery("from Contract where CustomerName = 'Sherman'").UniqueResult<Contract>(); -NHibernate.Initialize(contract.Plan); -NHibernate.Initialize(contract.Variations); -NHibernate.Initialize(contract.Notes); -session.DefaultReadOnly = false; -... -tx.Commit(); -session.Close(); - - + (); + NHibernate.Initialize(contract.Plan); + NHibernate.Initialize(contract.Variations); + NHibernate.Initialize(contract.Notes); + session.DefaultReadOnly = false; + ... + tx.Commit(); +}]]> If Session.DefaultReadOnly returns true, then you can @@ -511,26 +508,24 @@ s.Flush(); will not increment the version if any simple properties change. - -ISession session = factory.OpenSession(); -ITransaction tx = session.BeginTransaction(); - -// get a contract and make it read-only -Contract contract = session.Get<Contract>(contractId); -session.SetReadOnly(contract, true); + (contractId); + session.SetReadOnly(contract, true); -// contract.CustomerName is "Sherman" -contract.CustomerName = "Yogi"; -tx.Commit(); + // contract.CustomerName is "Sherman" + contract.CustomerName = "Yogi"; + tx.Commit(); -tx = session.BeginTransaction(); + tx = session.BeginTransaction(); -contract = session.Get<Contract>(contractId); -// contract.CustomerName is still "Sherman" -... -tx.Commit(); -session.Close(); - + contract = session.Get(contractId); + // contract.CustomerName is still "Sherman" + ... + tx.Commit(); +}]]> @@ -594,22 +589,26 @@ session.Close(); on the entity's database representation. -// get a contract with an existing plan; -// make the contract read-only and set its plan to null -tx = session.BeginTransaction(); -Contract contract = session.Get<Contract>(contractId); -session.SetReadOnly(contract, true); -contract.Plan = null; -tx.Commit(); + (contractId); + session.SetReadOnly(contract, true); + contract.Plan = null; + tx.Commit(); +} // get the same contract -tx = session.BeginTransaction(); -Contract contract = session.Get<Contract>(contractId); +using (var tx = session.BeginTransaction()) +{ + Contract contract = session.Get(contractId); -// contract.Plan still refers to the original plan; + // contract.Plan still refers to the original plan; -tx.Commit(); -session.Close(); + tx.Commit(); +} +session.Close();]]> The following shows that, even though @@ -620,26 +619,32 @@ session.Close(); changed association. -// get a contract with an existing plan; + (contractId); + session.SetReadOnly(contract, true); + newPlan = new Plan("new plan"); + contract.Plan = newPlan; + tx.Commit(); +} // get the same contract -tx = session.BeginTransaction(); -contract = session.Get<Contract>(contractId); -newPlan = session.Get<Plan>(newPlan.Id); - -// contract.Plan still refers to the original plan; -// newPlan is non-null because it was persisted when -// the previous transaction was committed; - -tx.Commit(); -session.Close(); +using (var tx = session.BeginTransaction()) +{ + contract = session.Get(contractId); + newPlan = session.Get(newPlan.Id); + + // contract.Plan still refers to the original plan; + // newPlan is non-null because it was persisted when + // the previous transaction was committed; + + tx.Commit(); +} +session.Close();]]> diff --git a/doc/reference/modules/transactions.xml b/doc/reference/modules/transactions.xml index 5fed5c6e632..5e26447eef2 100644 --- a/doc/reference/modules/transactions.xml +++ b/doc/reference/modules/transactions.xml @@ -174,10 +174,12 @@ @@ -210,12 +212,13 @@ session.Disconnect();]]> +using (var session = factory.OpenSession()) +using (var transaction = session.BeginTransaction()) +{ + session.SaveOrUpdate(foo); + session.Flush(); + transaction.Commit(); +}]]> You may also call Lock() instead of Update() @@ -280,15 +283,16 @@ session.Close();]]> +using (var session = factory.OpenSession()) +using (var transaction = session.BeginTransaction()) +{ + int oldVersion = foo.Version; + session.Load( foo, foo.Key ); + if ( oldVersion != foo.Version ) throw new StaleObjectStateException(); + foo.Property = "bar"; + session.Flush(); + transaction.Commit(); +}]]> Of course, if you are operating in a low-data-concurrency environment and don't @@ -337,7 +341,7 @@ session.close();]]> - Heres an example: + Here is an example: - As of NHibernate, if your application manages transactions through .NET APIs such as - System.Transactions library, ConnectionReleaseMode.AfterTransaction may cause + If your application manages transactions through .NET APIs such as System.Transactions library + while not using a compatible transaction factory (see transaction.factory_class + in ), ConnectionReleaseMode.AfterTransaction may cause NHibernate to open and close several connections during one transaction, leading to unnecessary overhead and transaction promotion from local to distributed. Specifying ConnectionReleaseMode.OnClose will revert to the legacy behavior and prevent this problem from occurring. @@ -578,5 +583,92 @@ finally + + Transaction scopes (System.Transactions) + + + Instead of using NHibernate ITransaction, TransactionScope + can be used. Please do not use both simultaneously. Using TransactionScope + requires using a compatible transaction factory (see transaction.factory_class + in ). The default transaction factory supports scopes. + + + + When using TransactionScope with NHibernate, you need to be aware of following + points: + + + + + + The session will enlist with the first scope in which the session is used (or opened). + As of NHibernate v5.0, it will enlist its connection in the transaction regardless of + connection string Enlist setting. Prior to v5.0, it was relying on + that setting being considered true, and on acquiring the connection + within the scope. + + + Sub-scopes are not supported. The session will be enlisted in the first scope within + which it was used, until this scope is committed or rollback. If auto-enlistment is + enabled on the connection and the session used on others scopes than the one in which + it is currently enlisted, the connection may enlist in another scope, and the session + will then fail to use it. + + + As of NHibernate v5.0, session auto-enlistment can be disabled from the session builder + obtained with ISessionFactory.WithOptions(), using the + AutoJoinTransaction option. The connection may still enlist itself + if connection string Enlist setting is not false. + A session can explicitly join the current system transaction by calling + ISession.JoinTransaction(). + + + + + As of NHibernate v5.0, FlushMode.Commit requires the configuration setting + transaction.use_connection_on_system_events to be true for flushing + from transaction scope commit. Otherwise, it will be your responsibility to flush the session + before completing the scope. + + + Using transaction.use_connection_on_system_events can cause undesired + transaction promotions to distributed: it requires using a dedicated connection for flushing, + and it delays session disposal (if done inside the scope) to the scope disposal. If you want + to avoid this, set this setting to false and manually flush your sessions. + + + + + As of NHibernate v5.0, ConnectionReleaseMode.AfterTransaction has no more + by default an "immediate" effect with transaction scopes. Previously, it was releasing the + connection from transaction completion events. But this is not officially supported by + Microsoft and this can cause issues especially with distributed transactions. + + + Since v5.0, by default, the connection will be actually released after the scope disposal at the + first session usage involving a connection, or at the session closing, whichever come first. + Alternatively, you may Disconnect() the session. (Requires + Reconnect() before re-using the session.) + + + When using transaction.use_connection_on_system_events, if the session is + disposed within the scope, the connection releasing will still occurs from transaction + completion event. + + + + + As of NHibernate v5.0, using transaction scope and trying to use the session connection within + AfterTransactionCompletion is forbidden and will raise an exception. + If the setting transaction.use_connection_on_system_events + is false, it will forbid any connection usage from + BeforeTransactionCompletion event too, when this event is triggered by + a transaction scope commit or rollback. + + + + + + diff --git a/src/AsyncGenerator.yml b/src/AsyncGenerator.yml index 7a76e51730f..a7a9c6cf903 100644 --- a/src/AsyncGenerator.yml +++ b/src/AsyncGenerator.yml @@ -109,6 +109,12 @@ typeConversion: - conversion: Ignore name: EnumerableImpl + - conversion: Ignore + name: SystemTransactionContext + containingTypeName: AdoNetWithSystemTransactionFactory + - conversion: Ignore + name: DependentContext + containingTypeName: AdoNetWithSystemTransactionFactory ignoreSearchForAsyncCounterparts: - name: GetFieldValue - name: IsDBNull diff --git a/src/NHibernate.Test/Async/DebugSessionFactory.cs b/src/NHibernate.Test/Async/DebugSessionFactory.cs index e6c798758e4..7a771ea30dc 100644 --- a/src/NHibernate.Test/Async/DebugSessionFactory.cs +++ b/src/NHibernate.Test/Async/DebugSessionFactory.cs @@ -13,7 +13,6 @@ using System.Collections.Generic; using System.Data.Common; using System.Linq; -using System.Threading; using log4net; using NHibernate.Cache; using NHibernate.Cfg; @@ -37,6 +36,7 @@ namespace NHibernate.Test { using System.Threading.Tasks; + using System.Threading; /// /// Contains generated async methods /// diff --git a/src/NHibernate.Test/Async/DynamicProxyTests/PeVerifier.cs b/src/NHibernate.Test/Async/DynamicProxyTests/PeVerifier.cs new file mode 100644 index 00000000000..579d8dc9f8b --- /dev/null +++ b/src/NHibernate.Test/Async/DynamicProxyTests/PeVerifier.cs @@ -0,0 +1,51 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Diagnostics; +using System.IO; +using NUnit.Framework; + +namespace NHibernate.Test.DynamicProxyTests +{ + using System.Threading.Tasks; + + /// + /// Contains generated async methods + /// + + public partial class PeVerifier + { + + public async Task AssertIsValidAsync() + { + var process = new Process + { + StartInfo = + { + FileName = _peVerifyPath, + RedirectStandardOutput = true, + UseShellExecute = false, + Arguments = "\"" + _assemlyLocation + "\" /VERBOSE", + CreateNoWindow = true + } + }; + + process.Start(); + var processOutput = await (process.StandardOutput.ReadToEndAsync()); + process.WaitForExit(); + + var result = process.ExitCode + " code "; + + if (process.ExitCode != 0) + Assert.Fail("PeVerify reported error(s): " + Environment.NewLine + processOutput, result); + } + } +} diff --git a/src/NHibernate.Test/Async/DynamicProxyTests/PeVerifyFixture.cs b/src/NHibernate.Test/Async/DynamicProxyTests/PeVerifyFixture.cs new file mode 100644 index 00000000000..eaaead249a5 --- /dev/null +++ b/src/NHibernate.Test/Async/DynamicProxyTests/PeVerifyFixture.cs @@ -0,0 +1,133 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.IO; +using System.Reflection; +using System.Reflection.Emit; +using NUnit.Framework; +using NHibernate.Proxy.DynamicProxy; + +namespace NHibernate.Test.DynamicProxyTests +{ + using System.Threading.Tasks; + [TestFixture] + public class PeVerifyFixtureAsync + { + private static bool wasCalled; + + private const string assemblyName = "peVerifyAssembly"; + private const string assemblyFileName = "peVerifyAssembly.dll"; + + [Test] + public Task VerifyClassWithPublicConstructorAsync() + { + try + { + var factory = new ProxyFactory(new SavingProxyAssemblyBuilder(assemblyName)); + var proxyType = factory.CreateProxyType(typeof(ClassWithPublicDefaultConstructor), null); + + wasCalled = false; + Activator.CreateInstance(proxyType); + + Assert.That(wasCalled); + return new PeVerifier(assemblyFileName).AssertIsValidAsync(); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + [Test] + public Task VerifyClassWithProtectedConstructorAsync() + { + try + { + var factory = new ProxyFactory(new SavingProxyAssemblyBuilder(assemblyName)); + var proxyType = factory.CreateProxyType(typeof(ClassWithProtectedDefaultConstructor), null); + + wasCalled = false; + Activator.CreateInstance(proxyType); + + Assert.That(wasCalled); + return new PeVerifier(assemblyFileName).AssertIsValidAsync(); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + #region PeVerifyTypes + + public class ClassWithPublicDefaultConstructor + { + public ClassWithPublicDefaultConstructor() { InitG(1); } + public ClassWithPublicDefaultConstructor(int unused) { } + public virtual int Prop1 { get; set; } + public virtual void InitG(T value) { Init((int)(object)value); } + public virtual void Init(int value) { Prop1 = value; if (Prop1 == 1) wasCalled = true; } + } + + public class ClassWithProtectedDefaultConstructor + { + protected ClassWithProtectedDefaultConstructor() { wasCalled = true; } + } + + public class ClassWithPrivateDefaultConstructor + { + private ClassWithPrivateDefaultConstructor() { wasCalled = true; } + } + + public class ClassWithNoDefaultConstructor + { + public ClassWithNoDefaultConstructor(int unused) { wasCalled = true; } + public ClassWithNoDefaultConstructor(string unused) { wasCalled = true; } + } + + public class ClassWithInternalConstructor + { + internal ClassWithInternalConstructor() { wasCalled = true; } + } + + #endregion + + #region ProxyFactory.IProxyAssemblyBuilder + + public class SavingProxyAssemblyBuilder : IProxyAssemblyBuilder + { + private string assemblyName; + + public SavingProxyAssemblyBuilder(string assemblyName) + { + this.assemblyName = assemblyName; + } + + public AssemblyBuilder DefineDynamicAssembly(AppDomain appDomain, AssemblyName name) + { + AssemblyBuilderAccess access = AssemblyBuilderAccess.RunAndSave; + return appDomain.DefineDynamicAssembly(new AssemblyName(assemblyName), access, TestContext.CurrentContext.TestDirectory); + } + + public ModuleBuilder DefineDynamicModule(AssemblyBuilder assemblyBuilder, string moduleName) + { + return assemblyBuilder.DefineDynamicModule(moduleName, string.Format("{0}.mod", assemblyName), true); + } + + public void Save(AssemblyBuilder assemblyBuilder) + { + assemblyBuilder.Save(assemblyName + ".dll"); + } + } + + #endregion + } +} diff --git a/src/NHibernate.Test/Async/NHSpecificTest/DtcFailures/DtcFailuresFixture.cs b/src/NHibernate.Test/Async/NHSpecificTest/DtcFailures/DtcFailuresFixture.cs deleted file mode 100644 index 3f1eab65824..00000000000 --- a/src/NHibernate.Test/Async/NHSpecificTest/DtcFailures/DtcFailuresFixture.cs +++ /dev/null @@ -1,329 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by AsyncGenerator. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - - -using System; -using System.Collections; -using System.Linq; -using System.Reflection; -using System.Threading; -using System.Transactions; -using log4net; -using log4net.Repository.Hierarchy; -using NHibernate.Cfg; -using NHibernate.Cfg.MappingSchema; -using NHibernate.Dialect; -using NHibernate.Tool.hbm2ddl; -using NUnit.Framework; - -namespace NHibernate.Test.NHSpecificTest.DtcFailures -{ - using System.Threading.Tasks; - [TestFixture] - public class DtcFailuresFixtureAsync : TestCase - { - private static readonly ILog log = LogManager.GetLogger(typeof(DtcFailuresFixtureAsync)); - - protected override IList Mappings - { - get { return new[] {"NHSpecificTest.DtcFailures.Mappings.hbm.xml"}; } - } - - protected override string MappingsAssembly - { - get { return "NHibernate.Test"; } - } - - protected override bool AppliesTo(Dialect.Dialect dialect) - { - return TestDialect.GetTestDialect(dialect).SupportsDistributedTransactions; - } - - protected override void CreateSchema() - { - // Copied from Configure method. - Configuration config = new Configuration(); - if (TestConfigurationHelper.hibernateConfigFile != null) - config.Configure(TestConfigurationHelper.hibernateConfigFile); - - // Our override so we can set nullability on database column without NHibernate knowing about it. - config.BeforeBindMapping += BeforeBindMapping; - - // Copied from AddMappings methods. - Assembly assembly = Assembly.Load(MappingsAssembly); - foreach (string file in Mappings) - config.AddResource(MappingsAssembly + "." + file, assembly); - - // Copied from CreateSchema method, but we use our own config. - new SchemaExport(config).Create(false, true); - } - - private void BeforeBindMapping(object sender, BindMappingEventArgs e) - { - HbmProperty prop = e.Mapping.RootClasses[0].Properties.OfType().Single(p => p.Name == "NotNullData"); - prop.notnull = true; - prop.notnullSpecified = true; - } - - [Test] - public async Task WillNotCrashOnDtcPrepareFailureAsync() - { - var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); - using (ISession s = OpenSession()) - { - await (s.SaveAsync(new Person {NotNullData = null})); // Cause a SQL not null constraint violation. - } - - new ForceEscalationToDistributedTx(); - - tx.Complete(); - try - { - tx.Dispose(); - Assert.Fail("Expected failure"); - } - catch (AssertionException) - { - throw; - } - catch (Exception) {} - } - - [Test] - public async Task Can_roll_back_transactionAsync() - { - var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); - using (ISession s = OpenSession()) - { - new ForceEscalationToDistributedTx(true); //will rollback tx - await (s.SaveAsync(new Person { CreatedAt = DateTime.Today })); - - tx.Complete(); - } - try - { - tx.Dispose(); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - //expected - } - } - - [Test] - [Description("Another action inside the transaction do the rollBack outside nh-session-scope.")] - public async Task RollbackOutsideNhAsync() - { - try - { - using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - var person = new Person { CreatedAt = DateTime.Now }; - await (s.SaveAsync(person)); - } - new ForceEscalationToDistributedTx(true); //will rollback tx - - txscope.Complete(); - } - - log.DebugFormat("Transaction fail."); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - log.DebugFormat("Transaction aborted."); - } - } - - [Test] - [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] - public async Task TransactionInsertWithRollBackTaskAsync() - { - try - { - using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - var person = new Person {CreatedAt = DateTime.Now}; - await (s.SaveAsync(person)); - new ForceEscalationToDistributedTx(true); //will rollback tx - person.CreatedAt = DateTime.Now; - await (s.UpdateAsync(person)); - } - txscope.Complete(); - } - log.DebugFormat("Transaction fail."); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - log.DebugFormat("Transaction aborted."); - } - } - - [Test, Ignore("Not fixed.")] - [Description(@"Two session in two txscope -(without an explicit NH transaction and without an explicit flush) -and with a rollback in the second dtc and a ForceRollback outside nh-session-scope.")] - public async Task TransactionInsertLoadWithRollBackTaskAsync() - { - object savedId; - using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - var person = new Person {CreatedAt = DateTime.Now}; - savedId = await (s.SaveAsync(person)); - } - txscope.Complete(); - } - try - { - using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - var person = await (s.GetAsync(savedId)); - person.CreatedAt = DateTime.Now; - await (s.UpdateAsync(person)); - } - new ForceEscalationToDistributedTx(true); - - log.Debug("completing the tx scope"); - txscope.Complete(); - } - log.Debug("Transaction fail."); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - log.Debug("Transaction aborted."); - } - finally - { - using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - var person = await (s.GetAsync(savedId)); - await (s.DeleteAsync(person)); - } - txscope.Complete(); - } - } - } - - [Test] - public async Task CanDeleteItemInDtcAsync() - { - object id; - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - id = await (s.SaveAsync(new Person {CreatedAt = DateTime.Today})); - - new ForceEscalationToDistributedTx(); - - tx.Complete(); - } - } - - // Dodging "latency" due to db still haven't actually committed a distributed tx after scope disposal. - Thread.Sleep(100); - - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - new ForceEscalationToDistributedTx(); - - await (s.DeleteAsync(await (s.GetAsync(id)))); - - tx.Complete(); - } - } - - // Dodging "latency" due to db still haven't actually committed a distributed tx after scope disposal. - Thread.Sleep(100); - } - - [Test] - [Description("Open/Close a session inside a TransactionScope fails.")] - public async Task NH1744Async() - { - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - using (ISession s = OpenSession()) - { - await (s.FlushAsync()); - } - - using (ISession s = OpenSession()) - { - await (s.FlushAsync()); - } - - //and I always leave the transaction disposed without calling tx.Complete(), I let the database server to rollback all actions in this test. - } - } - - public class ForceEscalationToDistributedTx : IEnlistmentNotification - { - private readonly bool shouldRollBack; - private readonly int thread; - - public ForceEscalationToDistributedTx(bool shouldRollBack) - { - this.shouldRollBack = shouldRollBack; - thread = Thread.CurrentThread.ManagedThreadId; - System.Transactions.Transaction.Current.EnlistDurable(Guid.NewGuid(), this, EnlistmentOptions.None); - } - - public ForceEscalationToDistributedTx() : this(false) {} - - public void Prepare(PreparingEnlistment preparingEnlistment) - { - if (thread == Thread.CurrentThread.ManagedThreadId) - { - log.Warn("Thread.CurrentThread.ManagedThreadId ({0}) is same as creation thread"); - } - - if (shouldRollBack) - { - log.Debug(">>>>Force Rollback<<<<<"); - preparingEnlistment.ForceRollback(); - } - else - { - preparingEnlistment.Prepared(); - } - } - - public void Commit(Enlistment enlistment) - { - enlistment.Done(); - } - - public void Rollback(Enlistment enlistment) - { - enlistment.Done(); - } - - public void InDoubt(Enlistment enlistment) - { - enlistment.Done(); - } - } - } -} \ No newline at end of file diff --git a/src/NHibernate.Test/Async/NHSpecificTest/NH1054/DummyTransactionFactory.cs b/src/NHibernate.Test/Async/NHSpecificTest/NH1054/DummyTransactionFactory.cs index a5c8bfb2ba0..1a411c3a066 100644 --- a/src/NHibernate.Test/Async/NHSpecificTest/NH1054/DummyTransactionFactory.cs +++ b/src/NHibernate.Test/Async/NHSpecificTest/NH1054/DummyTransactionFactory.cs @@ -9,8 +9,7 @@ using System; -using System.Collections; -using NHibernate.AdoNet; +using System.Collections.Generic; using NHibernate.Engine; using NHibernate.Engine.Transaction; using NHibernate.Transaction; diff --git a/src/NHibernate.Test/Async/NHSpecificTest/NH1632/Fixture.cs b/src/NHibernate.Test/Async/NHSpecificTest/NH1632/Fixture.cs index 342b946ec01..d1b38098ad5 100644 --- a/src/NHibernate.Test/Async/NHSpecificTest/NH1632/Fixture.cs +++ b/src/NHibernate.Test/Async/NHSpecificTest/NH1632/Fixture.cs @@ -8,6 +8,7 @@ //------------------------------------------------------------------------------ +using System.Data; using NUnit.Framework; namespace NHibernate.Test.NHSpecificTest.NH1632 @@ -32,12 +33,15 @@ protected override void Configure(Configuration configuration) { configuration .SetProperty(Environment.UseSecondLevelCache, "true") - .SetProperty(Environment.CacheProvider, typeof (HashtableCacheProvider).AssemblyQualifiedName); + .SetProperty(Environment.CacheProvider, typeof(HashtableCacheProvider).AssemblyQualifiedName); } [Test] public async Task When_using_DTC_HiLo_knows_to_create_isolated_DTC_transactionAsync() { + if (!Dialect.SupportsConcurrentWritingConnections) + Assert.Ignore(Dialect.GetType().Name + " does not support concurrent writing connections, can not isolate work."); + object scalar1, scalar2; using (var session = Sfi.OpenSession()) @@ -47,18 +51,19 @@ public async Task When_using_DTC_HiLo_knows_to_create_isolated_DTC_transactionAs scalar1 = await (command.ExecuteScalarAsync()); } - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) { var generator = Sfi.GetIdentifierGenerator(typeof(Person).FullName); Assert.That(generator, Is.InstanceOf()); - using(var session = Sfi.OpenSession()) + using (var session = OpenSession()) { - var id = await (generator.GenerateAsync((ISessionImplementor) session, new Person(), CancellationToken.None)); + // Force connection acquisition for having it enlisted. + Assert.That(session.Connection.State, Is.EqualTo(ConnectionState.Open)); + await (generator.GenerateAsync((ISessionImplementor)session, new Person(), CancellationToken.None)); } // intentionally dispose without committing - tx.Dispose(); } using (var session = Sfi.OpenSession()) @@ -68,7 +73,7 @@ public async Task When_using_DTC_HiLo_knows_to_create_isolated_DTC_transactionAs scalar2 = await (command.ExecuteScalarAsync()); } - Assert.AreNotEqual(scalar1, scalar2,"HiLo must run with in its own transaction"); + Assert.AreNotEqual(scalar1, scalar2, "HiLo must run with in its own transaction"); } @@ -79,43 +84,49 @@ public async Task When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_ { using (var s = Sfi.OpenSession()) { - await (s.SaveAsync(new Nums {ID = 29, NumA = 1, NumB = 3})); + await (s.SaveAsync(new Nums { ID = 29, NumA = 1, NumB = 3 })); } tx.Complete(); } - - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + try { - using (var s = Sfi.OpenSession()) + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) { - var nums = await (s.LoadAsync(29)); - Assert.AreEqual(1, nums.NumA); - Assert.AreEqual(3, nums.NumB); + using (var s = OpenSession()) + { + var nums = await (s.LoadAsync(29)); + Assert.AreEqual(1, nums.NumA); + Assert.AreEqual(3, nums.NumB); + } + tx.Complete(); } - tx.Complete(); - } - //closing the connection to ensure we can't really use it. - var connection = await (Sfi.ConnectionProvider.GetConnectionAsync(CancellationToken.None)); - Sfi.ConnectionProvider.CloseConnection(connection); + //closing the connection to ensure we can't really use it. + var connection = await (Sfi.ConnectionProvider.GetConnectionAsync(CancellationToken.None)); + Sfi.ConnectionProvider.CloseConnection(connection); - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. using (var s = Sfi.WithOptions().Connection(connection).OpenSession()) { - var nums = await (s.LoadAsync(29)); + Nums nums = null; + Assert.DoesNotThrowAsync(async () => nums = await (s.LoadAsync(29)), "Failed loading entity from second level cache."); Assert.AreEqual(1, nums.NumA); Assert.AreEqual(3, nums.NumB); } - tx.Complete(); } - - using (var s = Sfi.OpenSession()) - using (var tx = s.BeginTransaction()) + finally { - var nums = await (s.LoadAsync(29)); - await (s.DeleteAsync(nums)); - await (tx.CommitAsync()); + using (var s = OpenSession()) + using (var tx = s.BeginTransaction()) + { + var nums = await (s.LoadAsync(29)); + await (s.DeleteAsync(nums)); + await (tx.CommitAsync()); + } } } @@ -169,9 +180,9 @@ public async Task Will_not_save_when_flush_mode_is_neverAsync() [Test] public async Task When_using_two_sessions_with_explicit_flushAsync() { - if (!TestDialect.SupportsConcurrentTransactions) - Assert.Ignore(Dialect.GetType().Name + " does not support concurrent transactions."); - if (!TestDialect.SupportsDistributedTransactions) + if (!Dialect.SupportsConcurrentWritingConnectionsInSameTransaction) + Assert.Ignore(Dialect.GetType().Name + " does not support concurrent connections in same transaction."); + if (!Dialect.SupportsDistributedTransactions) Assert.Ignore(Dialect.GetType().Name + " does not support distributed transactions."); object id1, id2; @@ -209,9 +220,9 @@ public async Task When_using_two_sessions_with_explicit_flushAsync() [Test] public async Task When_using_two_sessionsAsync() { - if (!TestDialect.SupportsConcurrentTransactions) - Assert.Ignore(Dialect.GetType().Name + " does not support concurrent transactions."); - if (!TestDialect.SupportsDistributedTransactions) + if (!Dialect.SupportsConcurrentWritingConnectionsInSameTransaction) + Assert.Ignore(Dialect.GetType().Name + " does not support concurrent connections in same transaction."); + if (!Dialect.SupportsDistributedTransactions) Assert.Ignore(Dialect.GetType().Name + " does not support distributed transactions."); object id1, id2; diff --git a/src/NHibernate.Test/Async/NHSpecificTest/NH2176/Fixture.cs b/src/NHibernate.Test/Async/NHSpecificTest/NH2176/Fixture.cs new file mode 100644 index 00000000000..ad9036fc089 --- /dev/null +++ b/src/NHibernate.Test/Async/NHSpecificTest/NH2176/Fixture.cs @@ -0,0 +1,87 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Transactions; +using NUnit.Framework; + +namespace NHibernate.Test.NHSpecificTest.NH2176 +{ + using System.Threading.Tasks; + [TestFixture] + public class FixtureAsync : BugTestCase + { + protected override void OnSetUp() + { + base.OnSetUp(); + using (var s = OpenSession()) + using (var tx = s.BeginTransaction()) + { + var steve = new Person { Name = "Steve" }; + var peter = new Person { Name = "Peter" }; + var simon = new Person { Name = "Simon" }; + var paul = new Person { Name = "Paul" }; + var john = new Person { Name = "John" }; + var eric = new Person { Name = "Eric" }; + + s.Save(steve); + s.Save(peter); + s.Save(simon); + s.Save(paul); + s.Save(john); + s.Save(eric); + + tx.Commit(); + } + } + + protected override void OnTearDown() + { + base.OnTearDown(); + using (var s = OpenSession()) + using (var tx = s.BeginTransaction()) + { + s.Delete("from Person"); + tx.Commit(); + } + } + + // Whilst this bug seems specific to Oracle I think it is valid to run the + // test against all database types. + [Test] + public async Task MultipleConsecutiveTransactionScopesCanBeUsedInsideASingleSessionAsync() + { + using (var s = OpenSession()) + { + // usually fails after just a few loops in oracle + // this can be run for 10000 loops in sql server without problem + for (var i = 0; i < 100; ++i) + { + Console.WriteLine(i.ToString()); + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var criteria = s.CreateCriteria(); + var people = await (criteria.ListAsync()); + + Assert.That(people.Count, Is.EqualTo(6)); + + scope.Complete(); + } + + // The exception is caused by a race condition between two threads. + // This can be demonstrated by uncommenting the following line which + // causes the test to run without an exception. + //System.Threading.Thread.Sleep(1000); + } + } + } + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/Async/NHSpecificTest/NH2420/Fixture.cs b/src/NHibernate.Test/Async/NHSpecificTest/NH2420/Fixture.cs index 9c9d820c77b..906b69792ae 100644 --- a/src/NHibernate.Test/Async/NHSpecificTest/NH2420/Fixture.cs +++ b/src/NHibernate.Test/Async/NHSpecificTest/NH2420/Fixture.cs @@ -8,7 +8,6 @@ //------------------------------------------------------------------------------ -using System; using System.Data.Common; using System.Data.Odbc; using System.Data.SqlClient; @@ -16,7 +15,6 @@ using System.Transactions; using NHibernate.Dialect; using NHibernate.Driver; -using NHibernate.Engine; using NUnit.Framework; using Environment = NHibernate.Cfg.Environment; @@ -66,44 +64,59 @@ public async Task ShouldBeAbleToReleaseSuppliedConnectionAfterDistributedTransac { string connectionString = FetchConnectionStringFromConfiguration(); ISession s; - using (var ts = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + DbConnection connection = null; + try { - // Enlisting DummyEnlistment as a durable resource manager will start - // a DTC transaction - System.Transactions.Transaction.Current.EnlistDurable( - DummyEnlistment.Id, - new DummyEnlistment(), - EnlistmentOptions.None); + using (var ts = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + // Enlisting DummyEnlistment as a durable resource manager will start + // a DTC transaction + System.Transactions.Transaction.Current.EnlistDurable( + DummyEnlistment.Id, + new DummyEnlistment(), + EnlistmentOptions.None); - DbConnection connection; - if (Sfi.ConnectionProvider.Driver.GetType() == typeof(OdbcDriver)) - connection = new OdbcConnection(connectionString); - else - connection = new SqlConnection(connectionString); + if (Sfi.ConnectionProvider.Driver.GetType() == typeof(OdbcDriver)) + connection = new OdbcConnection(connectionString); + else + connection = new SqlConnection(connectionString); - using (connection) - { await (connection.OpenAsync()); using (s = Sfi.WithOptions().Connection(connection).OpenSession()) { await (s.SaveAsync(new MyTable { String = "hello!" })); } - connection.Close(); - } + // The ts disposal may try to flush the session, which, depending on the native generator + // implementation for current dialect, may have something to do and will then try to use + // the supplied connection. dispose connection here => flaky test, failing for dialects + // not mandating an immediate insert on native generator save. + // Delaying the connection disposal to after ts disposal. - ts.Complete(); + ts.Complete(); + } + } + finally + { + connection?.Dispose(); } + // It appears neither the second phase of the 2PC nor TransactionCompleted + // event are guaranteed to be executed before exiting transaction scope disposal. + // When having only 2PC, the second phase tends to occur after reaching that point + // here. When having TransactionCompleted event, this event and the second phase + // tend to occur before reaching here. But some other NH cases demonstrate that + // TransactionCompleted may also occur "too late". + s.GetSessionImplementation().TransactionContext?.Wait(); + // Prior to the patch, an InvalidOperationException exception would occur in the // TransactionCompleted delegate at this point with the message, "Disconnect cannot // be called while a transaction is in progress". Although the exception can be // seen reported in the IDE, NUnit fails to see it. The TransactionCompleted event // fires *after* the transaction is committed and so it doesn't affect the success // of the transaction. - Assert.That(s.IsConnected, Is.False); - Assert.That(((ISessionImplementor)s).ConnectionManager.IsConnected, Is.False); - Assert.That(((ISessionImplementor)s).IsClosed, Is.True); + Assert.That(s.GetSessionImplementation().ConnectionManager.IsConnected, Is.False); + Assert.That(s.GetSessionImplementation().IsClosed, Is.True); } protected override void OnTearDown() diff --git a/src/NHibernate.Test/Async/NHSpecificTest/NH3023/DeadlockConnectionPoolIssueTest.cs b/src/NHibernate.Test/Async/NHSpecificTest/NH3023/DeadlockConnectionPoolIssueTest.cs new file mode 100644 index 00000000000..5541da25321 --- /dev/null +++ b/src/NHibernate.Test/Async/NHSpecificTest/NH3023/DeadlockConnectionPoolIssueTest.cs @@ -0,0 +1,338 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Data.SqlClient; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Transactions; +using log4net; +using log4net.Repository.Hierarchy; +using NHibernate.Cfg; +using NHibernate.Dialect; +using NHibernate.Driver; +using NHibernate.Engine; +using NUnit.Framework; + +namespace NHibernate.Test.NHSpecificTest.NH3023 +{ + using System.Threading.Tasks; + [TestFixture] + public class DeadlockConnectionPoolIssueAsync : BugTestCase + { + private static readonly ILog _log = LogManager.GetLogger(typeof(DeadlockConnectionPoolIssueAsync)); + + protected virtual bool UseConnectionOnSystemTransactionPrepare => true; + + protected override void Configure(Configuration configuration) + { + configuration.SetProperty( + Cfg.Environment.UseConnectionOnSystemTransactionPrepare, + UseConnectionOnSystemTransactionPrepare.ToString()); + } + + // Uses directly SqlConnection. + protected override bool AppliesTo(ISessionFactoryImplementor factory) + => factory.ConnectionProvider.Driver is SqlClientDriver && base.AppliesTo(factory); + + protected override bool AppliesTo(Dialect.Dialect dialect) + => dialect is MsSql2000Dialect && base.AppliesTo(dialect); + + protected override void OnSetUp() + { + RunScript("db-seed.sql"); + + ((Logger)_log.Logger).Level = log4net.Core.Level.Debug; + } + + protected override void OnTearDown() + { + // Before clearing the pool for dodging pool corruption, we need to wait + // for late transaction processing not yet ended. + Thread.Sleep(100); + // + // Hopefully this will clean up the pool so that teardown can succeed + // + SqlConnection.ClearAllPools(); + + RunScript("db-teardown.sql"); + + using (var s = OpenSession()) + { + s.CreateQuery("delete from System.Object").ExecuteUpdate(); + } + } + + [Theory] + public async Task ConnectionPoolCorruptionAfterDeadlockAsync(bool distributed, bool disposeSessionBeforeScope) + { + var tryCount = 0; + var id = 1; + do + { + tryCount++; + var missingDeadlock = false; + + try + { + _log.DebugFormat("Starting loop {0}", tryCount); + // When the connection is released from transaction completion, the scope disposal after deadlock + // takes up to 30 seconds (not at first try, but at subsequent tries). With additional logs, it + // appears this delay occurs at connection closing. Definitely, there is something which can go + // wrong when disposing a connection from transaction scope completion. + // Note that the transaction completion event can execute as soon as the deadlock occurs. It does + // not wait for the scope disposal. + var session = OpenSession(); + var scope = distributed ? CreateDistributedTransactionScope() : new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + try + { + _log.Debug("Session and scope opened"); + session.GetSessionImplementation().Factory.TransactionFactory + .EnlistInSystemTransactionIfNeeded(session.GetSessionImplementation()); + _log.Debug("Session enlisted"); + try + { + await (new DeadlockHelper().ForceDeadlockOnConnectionAsync((SqlConnection)session.Connection)); + } + catch (SqlException x) + { + // + // Deadlock error code is 1205. + // + if (x.Errors.Cast().Any(e => e.Number == 1205)) + { + // + // It did what it was supposed to do. + // + _log.InfoFormat("Expected deadlock on attempt {0}. {1}", tryCount, x.Message); + continue; + } + + // + // ? This shouldn't happen + // + Assert.Fail("Surprising exception when trying to force a deadlock: {0}", x); + } + + _log.WarnFormat("Initial session seemingly not deadlocked at attempt {0}", tryCount); + missingDeadlock = true; + + try + { + await (session.SaveAsync( + new DomainClass + { + Id = id++, + ByteData = new byte[] {1, 2, 3} + })); + + await (session.FlushAsync()); + if (tryCount < 10) + { + _log.InfoFormat("Initial session still usable, trying again"); + continue; + } + _log.InfoFormat("Initial session still usable after {0} attempts, finishing test", tryCount); + } + catch (Exception ex) + { + _log.Error("Failed to continue using the session after lacking deadlock.", ex); + // This exception would hide the transaction failure, if any. + //throw; + } + _log.Debug("Completing scope"); + scope.Complete(); + _log.Debug("Scope completed"); + } + finally + { + // Check who takes time in the disposing + var chrono = new Stopwatch(); + if (disposeSessionBeforeScope) + { + try + { + chrono.Start(); + session.Dispose(); + _log.Debug("Session disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal session disposal duration"); + } + catch (Exception ex) + { + // Log in case it gets hidden by the next finally + _log.Warn("Session disposal failure", ex); + throw; + } + finally + { + chrono.Restart(); + scope.Dispose(); + _log.Debug("Scope disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal scope disposal duration"); + } + } + else + { + try + { + chrono.Start(); + scope.Dispose(); + _log.Debug("Scope disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal scope disposal duration"); + } + catch (Exception ex) + { + // Log in case it gets hidden by the next finally + _log.Warn("Scope disposal failure", ex); + throw; + } + finally + { + chrono.Restart(); + session.Dispose(); + _log.Debug("Session disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal session disposal duration"); + } + } + } + _log.Debug("Session and scope disposed"); + } + catch (AssertionException) + { + throw; + } + catch (Exception x) + { + _log.Error($"Initial session failed at attempt {tryCount}.", x); + } + + var subsequentFailedRequests = 0; + + for (var i = 1; i <= 10; i++) + { + // + // The error message will vary on subsequent requests, so we'll somewhat + // arbitrarily try 10 + // + + try + { + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var session = OpenSession()) + { + await (session.SaveAsync( + new DomainClass + { + Id = id++, + ByteData = new byte[] { 1, 2, 3 } + })); + + await (session.FlushAsync()); + } + + scope.Complete(); + } + } + catch (Exception x) + { + subsequentFailedRequests++; + _log.Error($"Subsequent session {i} failed.", x); + } + } + + Assert.Fail("{0}; {1} subsequent requests failed.", + missingDeadlock + ? "Deadlock not reported on initial request, and initial request failed" + : "Initial request failed", + subsequentFailedRequests); + + } while (tryCount < 3); + // + // I'll change this to while(true) sometimes so I don't have to keep running the test + // + } + + private static TransactionScope CreateDistributedTransactionScope() + { + var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + // + // Forces promotion to distributed transaction + // + TransactionInterop.GetTransmitterPropagationToken(System.Transactions.Transaction.Current); + return scope; + } + + private async Task RunScriptAsync(string script, CancellationToken cancellationToken = default(CancellationToken)) + { + var cxnString = cfg.Properties["connection.connection_string"] + "; Pooling=No"; + // Disable connection pooling so this won't be hindered by + // problems encountered during the actual test + + string sql; + using (var reader = new StreamReader(GetType().Assembly.GetManifestResourceStream(GetType().Namespace + "." + script))) + { + sql = await (reader.ReadToEndAsync()); + } + + using (var cxn = new SqlConnection(cxnString)) + { + await (cxn.OpenAsync(cancellationToken)); + + foreach (var batch in Regex.Split(sql, @"^go\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline) + .Where(b => !string.IsNullOrEmpty(b))) + { + + using (var cmd = new System.Data.SqlClient.SqlCommand(batch, cxn)) + { + await (cmd.ExecuteNonQueryAsync(cancellationToken)); + } + } + } + } + + private void RunScript(string script) + { + var cxnString = cfg.Properties["connection.connection_string"] + "; Pooling=No"; + // Disable connection pooling so this won't be hindered by + // problems encountered during the actual test + + string sql; + using (var reader = new StreamReader(GetType().Assembly.GetManifestResourceStream(GetType().Namespace + "." + script))) + { + sql = reader.ReadToEnd(); + } + + using (var cxn = new SqlConnection(cxnString)) + { + cxn.Open(); + + foreach (var batch in Regex.Split(sql, @"^go\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline) + .Where(b => !string.IsNullOrEmpty(b))) + { + + using (var cmd = new System.Data.SqlClient.SqlCommand(batch, cxn)) + { + cmd.ExecuteNonQuery(); + } + } + } + } + } + + [TestFixture] + public class DeadlockConnectionPoolIssueWithoutConnectionFromPrepareAsync : DeadlockConnectionPoolIssueAsync + { + protected override bool UseConnectionOnSystemTransactionPrepare => false; + } +} diff --git a/src/NHibernate.Test/Async/NHSpecificTest/NH3023/DeadlockHelper.cs b/src/NHibernate.Test/Async/NHSpecificTest/NH3023/DeadlockHelper.cs new file mode 100644 index 00000000000..5b8ffc40221 --- /dev/null +++ b/src/NHibernate.Test/Async/NHSpecificTest/NH3023/DeadlockHelper.cs @@ -0,0 +1,125 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Data.SqlClient; +using System.Threading; +using System.Transactions; +using log4net; + +namespace NHibernate.Test.NHSpecificTest.NH3023 +{ + using System.Threading.Tasks; + /// + /// Contains generated async methods + /// + public partial class DeadlockHelper + { + + public async Task ForceDeadlockOnConnectionAsync(SqlConnection connection, CancellationToken cancellationToken = default(CancellationToken)) + { + using (var victimLock = new SemaphoreSlim(0)) + using (var winnerLock = new SemaphoreSlim(0)) + { + // + // Second thread with non-pooled connection, to deadlock + // with current thread + // + Exception winnerEx = null; + var winnerThread = new Thread( + () => + { + try + { + using (var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled)) + { + using (var cxn = new SqlConnection(connection.ConnectionString + ";Pooling=No")) + { + cxn.Open(); + DeadlockParticipant(cxn, false, winnerLock, victimLock); + } + scope.Complete(); + } + } + catch (Exception ex) + { + winnerEx = ex; + } + }); + + winnerThread.Start(); + + try + { + // + // This should always throw an exception of the form + // Transaction (Process ID nn) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. + // + await (DeadlockParticipantAsync(connection, true, victimLock, winnerLock, cancellationToken)); + } + finally + { + winnerThread.Join(); + if (winnerEx != null) + _log.Warn("Winner thread failed", winnerEx); + } + + // + // Should never get here + // + _log.Warn("Expected a deadlock exception for victim, but it was not raised."); + } + } + + private static async Task DeadlockParticipantAsync(SqlConnection connection, bool isVictim, SemaphoreSlim myLock, SemaphoreSlim partnerLock, CancellationToken cancellationToken = default(CancellationToken)) + { + try + { + // + // CLID = 1 has only 10 records, CLID = 3 has 100. This guarantees + // which process will be chosen as the victim (the one which will have + // less work to rollback) + // + var clid = isVictim ? 1 : 3; + using (var cmd = new System.Data.SqlClient.SqlCommand("UPDATE DeadlockHelper SET Data = newid() WHERE CLId = @CLID", connection)) + { + // + // Exclusive lock on some records in the table + // + cmd.Parameters.AddWithValue("@CLID", clid); + await (cmd.ExecuteNonQueryAsync(cancellationToken)); + } + } + finally + { + // + // Notify partner that I have finished my work + // + myLock.Release(); + } + // + // Wait for partner to finish its work + // + if (!await (partnerLock.WaitAsync(120000, cancellationToken))) + { + throw new InvalidOperationException("Wait for partner has taken more than two minutes"); + } + + using (var cmd = new System.Data.SqlClient.SqlCommand("SELECT TOP 1 Data FROM DeadlockHelper ORDER BY Data", connection)) + { + // + // Requires shared lock on table, should be blocked by + // partner's exclusive lock + // + await (cmd.ExecuteNonQueryAsync(cancellationToken)); + } + } + } +} diff --git a/src/NHibernate.Test/Async/SessionBuilder/Fixture.cs b/src/NHibernate.Test/Async/SessionBuilder/Fixture.cs index a51811b6bf4..ac089680980 100644 --- a/src/NHibernate.Test/Async/SessionBuilder/Fixture.cs +++ b/src/NHibernate.Test/Async/SessionBuilder/Fixture.cs @@ -24,7 +24,7 @@ public class FixtureAsync : TestCase { protected override string MappingsAssembly => "NHibernate.Test"; - protected override IList Mappings => new [] { "SessionBuilder.Mappings.hbm.xml" }; + protected override IList Mappings => new[] { "SessionBuilder.Mappings.hbm.xml" }; protected override void Configure(Configuration configuration) { @@ -37,12 +37,23 @@ private void CanSetAutoClose(T sb) where T : ISessionBuilder var options = DebugSessionFactory.GetCreationOptions(sb); CanSet(sb, sb.AutoClose, () => options.ShouldAutoClose, sb is ISharedSessionBuilder ssb ? ssb.AutoClose : default(Func), - // initial values + // initial value false, // values true, false); } + private void CanSetAutoJoinTransaction(T sb) where T : ISessionBuilder + { + var options = DebugSessionFactory.GetCreationOptions(sb); + CanSet(sb, sb.AutoJoinTransaction, () => options.ShouldAutoJoinTransaction, + sb is ISharedSessionBuilder ssb ? ssb.AutoJoinTransaction : default(Func), + // initial value + true, + // values + false, true); + } + [Test] public async Task CanSetConnectionAsync() { @@ -127,7 +138,7 @@ private void CanSetConnectionReleaseMode(T sb) where T : ISessionBuilder var options = DebugSessionFactory.GetCreationOptions(sb); CanSet(sb, sb.ConnectionReleaseMode, () => options.SessionConnectionReleaseMode, sb is ISharedSessionBuilder ssb ? ssb.ConnectionReleaseMode : default(Func), - // initial values + // initial value Sfi.Settings.ConnectionReleaseMode, // values ConnectionReleaseMode.OnClose, ConnectionReleaseMode.AfterStatement, ConnectionReleaseMode.AfterTransaction); @@ -138,7 +149,7 @@ private void CanSetFlushMode(T sb) where T : ISessionBuilder var options = DebugSessionFactory.GetCreationOptions(sb); CanSet(sb, sb.FlushMode, () => options.InitialSessionFlushMode, sb is ISharedSessionBuilder ssb ? ssb.FlushMode : default(Func), - // initial values + // initial value Sfi.Settings.DefaultFlushMode, // values FlushMode.Always, FlushMode.Auto, FlushMode.Commit, FlushMode.Manual); diff --git a/src/NHibernate.Test/Async/SystemTransactions/DistributedSystemTransactionFixture.cs b/src/NHibernate.Test/Async/SystemTransactions/DistributedSystemTransactionFixture.cs new file mode 100644 index 00000000000..ace2d591fbc --- /dev/null +++ b/src/NHibernate.Test/Async/SystemTransactions/DistributedSystemTransactionFixture.cs @@ -0,0 +1,769 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Linq; +using System.Threading; +using System.Transactions; +using log4net; +using log4net.Repository.Hierarchy; +using NHibernate.Cfg; +using NHibernate.Engine; +using NHibernate.Linq; +using NHibernate.Test.TransactionTest; +using NUnit.Framework; + +namespace NHibernate.Test.SystemTransactions +{ + using System.Threading.Tasks; + [TestFixture] + public class DistributedSystemTransactionFixtureAsync : SystemTransactionFixtureBase + { + private static readonly ILog _log = LogManager.GetLogger(typeof(DistributedSystemTransactionFixtureAsync)); + protected override bool UseConnectionOnSystemTransactionPrepare => true; + protected override bool AutoJoinTransaction => true; + + protected override bool AppliesTo(Dialect.Dialect dialect) + => dialect.SupportsDistributedTransactions && base.AppliesTo(dialect); + + protected override void OnTearDown() + { + DodgeTransactionCompletionDelayIfRequired(); + base.OnTearDown(); + } + + [Test] + public async Task SupportsEnlistingInDistributedAsync() + { + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + ForceEscalationToDistributedTx.Escalate(); + + Assert.AreNotEqual( + Guid.Empty, + System.Transactions.Transaction.Current.TransactionInformation.DistributedIdentifier, + "Transaction lacks a distributed identifier"); + + using (var s = OpenSession()) + { + await (s.SaveAsync(new Person())); + // Ensure the connection is acquired (thus enlisted) + Assert.DoesNotThrowAsync(() => s.FlushAsync(), "Failure enlisting a connection in a distributed transaction."); + } + } + } + + [Test] + public async Task SupportsPromotingToDistributedAsync() + { + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + await (s.SaveAsync(new Person())); + // Ensure the connection is acquired (thus enlisted) + await (s.FlushAsync()); + } + Assert.DoesNotThrow(() => ForceEscalationToDistributedTx.Escalate(), + "Failure promoting the transaction to distributed while already having enlisted a connection."); + Assert.AreNotEqual( + Guid.Empty, + System.Transactions.Transaction.Current.TransactionInformation.DistributedIdentifier, + "Transaction lacks a distributed identifier"); + } + } + + [Test] + public async Task WillNotCrashOnPrepareFailureAsync() + { + IgnoreIfUnsupported(false); + var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + var disposeCalled = false; + try + { + using (var s = OpenSession()) + { + await (s.SaveAsync(new Person { NotNullData = null })); // Cause a SQL not null constraint violation. + } + + ForceEscalationToDistributedTx.Escalate(); + + tx.Complete(); + disposeCalled = true; + Assert.Throws(tx.Dispose, "Scope disposal has not rollback and throw."); + } + finally + { + if (!disposeCalled) + { + try + { + tx.Dispose(); + } + catch + { + // Ignore, if disposed has not been called, another exception has occurred in the try and + // we should avoid overriding it by the disposal failure. + } + } + } + } + + [Theory] + public async Task CanRollbackTransactionAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + var disposeCalled = false; + try + { + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(true); //will rollback tx + await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + disposeCalled = true; + Assert.Throws(tx.Dispose, "Scope disposal has not rollback and throw."); + } + finally + { + if (!disposeCalled) + { + try + { + tx.Dispose(); + } + catch + { + // Ignore, if disposed has not been called, another exception has occurred in the try and + // we should avoid overriding it by the disposal failure. + } + } + } + + AssertNoPersons(); + } + + [Theory] + public async Task CanRollbackTransactionFromScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(); + await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + // No Complete call for triggering rollback. + } + + AssertNoPersons(); + } + + [Theory] + [Description("Another action inside the transaction do the rollBack outside nh-session-scope.")] + public async Task RollbackOutsideNhAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + try + { + using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new Person(); + await (s.SaveAsync(person)); + + if (explicitFlush) + await (s.FlushAsync()); + } + ForceEscalationToDistributedTx.Escalate(true); //will rollback tx + + txscope.Complete(); + } + + Assert.Fail("Scope disposal has not rollback and throw."); + } + catch (TransactionAbortedException) + { + _log.Debug("Transaction aborted."); + } + + AssertNoPersons(); + } + + [Theory] + [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] + public async Task TransactionInsertWithRollBackFromScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new Person(); + await (s.SaveAsync(person)); + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + await (s.FlushAsync()); + } + // No Complete call for triggering rollback. + } + AssertNoPersons(); + } + + [Theory] + [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] + public async Task TransactionInsertWithRollBackTaskAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + try + { + using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new Person(); + await (s.SaveAsync(person)); + ForceEscalationToDistributedTx.Escalate(true); //will rollback tx + + if (explicitFlush) + await (s.FlushAsync()); + } + txscope.Complete(); + } + + Assert.Fail("Scope disposal has not rollback and throw."); + } + catch (TransactionAbortedException) + { + _log.Debug("Transaction aborted."); + } + + AssertNoPersons(); + } + + [Theory] + [Description(@"Two session in two txscope + (without an explicit NH transaction) + and with a rollback in the second dtc and a rollback outside nh-session-scope.")] + public async Task TransactionInsertLoadWithRollBackFromScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object savedId; + var createdAt = DateTime.Today; + using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new Person { CreatedAt = createdAt }; + savedId = await (s.SaveAsync(person)); + + if (explicitFlush) + await (s.FlushAsync()); + } + txscope.Complete(); + } + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = await (s.GetAsync(savedId)); + person.CreatedAt = createdAt.AddMonths(-1); + + if (explicitFlush) + await (s.FlushAsync()); + } + ForceEscalationToDistributedTx.Escalate(); + + // No Complete call for triggering rollback. + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(createdAt, (await (s.GetAsync(savedId))).CreatedAt, "Entity update was not rollback-ed."); + } + } + + [Theory] + [Description(@"Two session in two txscope + (without an explicit NH transaction) + and with a rollback in the second dtc and a ForceRollback outside nh-session-scope.")] + public async Task TransactionInsertLoadWithRollBackTaskAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object savedId; + var createdAt = DateTime.Today; + using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new Person { CreatedAt = createdAt }; + savedId = await (s.SaveAsync(person)); + + if (explicitFlush) + await (s.FlushAsync()); + } + txscope.Complete(); + } + + try + { + using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = await (s.GetAsync(savedId)); + person.CreatedAt = createdAt.AddMonths(-1); + + if (explicitFlush) + await (s.FlushAsync()); + } + ForceEscalationToDistributedTx.Escalate(true); + + _log.Debug("completing the tx scope"); + txscope.Complete(); + } + _log.Debug("Transaction fail."); + Assert.Fail("Expected tx abort"); + } + catch (TransactionAbortedException) + { + _log.Debug("Transaction aborted."); + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(createdAt, (await (s.GetAsync(savedId))).CreatedAt, "Entity update was not rollback-ed."); + } + } + + [Theory] + public async Task CanDeleteItemInDtcAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object id; + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + id = await (s.SaveAsync(new Person())); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + } + + DodgeTransactionCompletionDelayIfRequired(); + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(1, await (s.Query().CountAsync()), "Entity not found in database."); + } + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(); + + await (s.DeleteAsync(await (s.GetAsync(id)))); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + } + + DodgeTransactionCompletionDelayIfRequired(); + + AssertNoPersons(); + } + + [Test] + [Description("Open/Close a session inside a TransactionScope fails.")] + public async Task NH1744Async() + { + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + await (s.FlushAsync()); + } + + using (var s = OpenSession()) + { + await (s.FlushAsync()); + } + + //and I always leave the transaction disposed without calling tx.Complete(), I let the database server to rollback all actions in this test. + } + } + + [Theory] + public async Task CanUseSessionWithManyScopesAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + // Note that this fails with ConnectionReleaseMode.OnClose and SqlServer: + // System.Data.SqlClient.SqlException : Microsoft Distributed Transaction Coordinator (MS DTC) has stopped this transaction. + // Not much an issue since it is advised to not use ConnectionReleaseMode.OnClose. + using (var s = OpenSession()) + //using (var s = Sfi.WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + // Acquire the connection + var count = await (s.Query().CountAsync()); + Assert.That(count, Is.EqualTo(0), "Unexpected initial entity count."); + tx.Complete(); + } + // No dodge here please! Allow to check chaining usages do not fail. + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + + DodgeTransactionCompletionDelayIfRequired(); + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = await (s.Query().CountAsync()); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after committed insert."); + tx.Complete(); + } + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + await (s.FlushAsync()); + + // No complete for rollback-ing. + } + + DodgeTransactionCompletionDelayIfRequired(); + + // Do not reuse the session after a rollback, its state does not allow it. + // http://nhibernate.info/doc/nhibernate-reference/manipulatingdata.html#manipulatingdata-endingsession-commit + } + + using (var s = OpenSession()) + { + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = await (s.Query().CountAsync()); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after rollback-ed insert."); + tx.Complete(); + } + } + } + + [Theory] + public async Task CanUseSessionOutsideOfScopeAfterScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + // Note that this fails with ConnectionReleaseMode.OnClose and Npgsql (< 3.2.5?): + // NpgsqlOperationInProgressException: The connection is already in state 'Executing' + // Not much an issue since it is advised to not use ConnectionReleaseMode.OnClose. + using (var s = OpenSession()) + //using (var s = WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + var count = 0; + Assert.DoesNotThrowAsync(async () => count = await (s.Query().CountAsync()), "Failed using the session after scope."); + if (count != 1) + // We are not testing that here, so just issue a warning. Do not use DodgeTransactionCompletionDelayIfRequired + // before previous assert. We want to ascertain the session is usable in any cases. + Assert.Warn("Unexpected entity count: {0} instead of {1}. The transaction seems to have a delayed commit.", count, 1); + } + } + + [Theory] + [Description("Do not fail, but warn in case a delayed after scope disposal commit is made.")] + public async Task DelayedTransactionCompletionAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + for (var i = 1; i <= 10; i++) + { + // Isolation level must be read committed on the control session: reading twice while expecting some data insert + // in between due to a late commit. Repeatable read would block and read uncommitted would see the uncommitted data. + using (var controlSession = OpenSession()) + using (controlSession.BeginTransaction(System.Data.IsolationLevel.ReadCommitted)) + { + // We want to have the control session as ready to query as possible, thus beginning its + // transaction early for acquiring the connection, even if we will not use it before + // below scope completion. + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + await (s.SaveAsync(new Person())); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + await (s.FlushAsync()); + } + tx.Complete(); + } + + var count = await (controlSession.Query().CountAsync()); + if (count != i) + { + // See https://github.com/npgsql/npgsql/issues/1571#issuecomment-308651461 discussion with a Microsoft + // employee: MSDTC consider a transaction to be committed once it has collected all participant votes + // for committing from prepare phase. It then immediately notifies all participants of the outcome. + // This causes TransactionScope.Dispose to leave while the second phase of participants may still + // be executing. This means the transaction from the db view point can still be pending and not yet + // committed. This is by design of MSDTC and we have to cope with that. Some data provider may have + // a global locking mechanism causing any subsequent use to wait for the end of the commit phase, + // but this is not the usual case. Some other, as Npgsql < v3.2.5, may crash due to this, because + // they re-use the connection for the second phase. + Thread.Sleep(100); + var countSecondTry = await (controlSession.Query().CountAsync()); + Assert.Warn($"Unexpected entity count: {count} instead of {i}. " + + "This may mean current data provider has a delayed commit, occurring after scope disposal. " + + $"After waiting, count is now {countSecondTry}. "); + break; + } + } + } + } + + // Taken and adjusted from NH1632 When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_level_cache + [Test] + public async Task WhenCommittingItemsAfterSessionDisposalWillAddThemTo2ndLevelCacheAsync() + { + int id; + const string notNullData = "test"; + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new CacheablePerson { NotNullData = notNullData }; + await (s.SaveAsync(person)); + id = person.Id; + + ForceEscalationToDistributedTx.Escalate(); + + await (s.FlushAsync()); + } + tx.Complete(); + } + + DodgeTransactionCompletionDelayIfRequired(); + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(); + + var person = await (s.LoadAsync(id)); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + tx.Complete(); + } + + // Closing the connection to ensure we can't actually use it. + var connection = await (Sfi.ConnectionProvider.GetConnectionAsync(CancellationToken.None)); + Sfi.ConnectionProvider.CloseConnection(connection); + + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. + using (var s = WithOptions().Connection(connection).OpenSession()) + { + CacheablePerson person = null; + Assert.DoesNotThrowAsync(async () => person = await (s.LoadAsync(id)), "Failed loading entity from second level cache."); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + } + + [Test] + public async Task DoNotDeadlockOnAfterTransactionWaitAsync() + { + var interceptor = new AfterTransactionWaitingInterceptor(); + using (var s = Sfi.WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + await (s.FlushAsync()); + tx.Complete(); + } + Assert.That(interceptor.Exception, Is.Null); + } + + [Test] + public async Task EnforceConnectionUsageRulesOnTransactionCompletionAsync() + { + var interceptor = new TransactionCompleteUsingConnectionInterceptor(); + // Do not invert session and scope, it would cause an expected failure when + // UseConnectionOnSystemTransactionEvents is false, due to the session being closed. + using (var s = Sfi.WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + await (s.FlushAsync()); + tx.Complete(); + } + + if (UseConnectionOnSystemTransactionPrepare) + { + Assert.That(interceptor.BeforeException, Is.Null); + } + else + { + Assert.That(interceptor.BeforeException, Is.TypeOf()); + } + // Currently always forbidden, whatever UseConnectionOnSystemTransactionEvents. + Assert.That(interceptor.AfterException, Is.TypeOf()); + } + + private void DodgeTransactionCompletionDelayIfRequired() + { + if (Sfi.ConnectionProvider.Driver.HasDelayedDistributedTransactionCompletion) + Thread.Sleep(500); + } + + public class ForceEscalationToDistributedTx : IEnlistmentNotification + { + private readonly bool _shouldRollBack; + private readonly int _thread; + + public static void Escalate(bool shouldRollBack = false) + { + var force = new ForceEscalationToDistributedTx(shouldRollBack); + System.Transactions.Transaction.Current.EnlistDurable(Guid.NewGuid(), force, EnlistmentOptions.None); + } + + private ForceEscalationToDistributedTx(bool shouldRollBack) + { + _shouldRollBack = shouldRollBack; + _thread = Thread.CurrentThread.ManagedThreadId; + } + + public void Prepare(PreparingEnlistment preparingEnlistment) + { + if (_thread == Thread.CurrentThread.ManagedThreadId) + { + _log.Warn("Thread.CurrentThread.ManagedThreadId ({0}) is same as creation thread"); + } + + if (_shouldRollBack) + { + _log.Debug(">>>>Force Rollback<<<<<"); + preparingEnlistment.ForceRollback(); + } + else + { + preparingEnlistment.Prepared(); + } + } + + public void Commit(Enlistment enlistment) + { + enlistment.Done(); + } + + public void Rollback(Enlistment enlistment) + { + enlistment.Done(); + } + + public void InDoubt(Enlistment enlistment) + { + enlistment.Done(); + } + } + } + + [TestFixture] + public class DistributedSystemTransactionWithoutConnectionFromPrepareFixtureAsync : DistributedSystemTransactionFixtureAsync + { + protected override bool UseConnectionOnSystemTransactionPrepare => false; + } + + [TestFixture] + public class DistributedSystemTransactionWithoutAutoJoinTransactionAsync : DistributedSystemTransactionFixtureAsync + { + protected override bool AutoJoinTransaction => false; + + protected override void Configure(Configuration configuration) + { + base.Configure(configuration); + DisableConnectionAutoEnlist(configuration); + } + + protected override bool AppliesTo(ISessionFactoryImplementor factory) + => base.AppliesTo(factory) && factory.ConnectionProvider.Driver.SupportsEnlistmentWhenAutoEnlistmentIsDisabled; + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/Async/SystemTransactions/SystemTransactionFixture.cs b/src/NHibernate.Test/Async/SystemTransactions/SystemTransactionFixture.cs new file mode 100644 index 00000000000..0e8f2ea6340 --- /dev/null +++ b/src/NHibernate.Test/Async/SystemTransactions/SystemTransactionFixture.cs @@ -0,0 +1,532 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Transactions; +using NHibernate.Cfg; +using NHibernate.Engine; +using NHibernate.Linq; +using NHibernate.Test.TransactionTest; +using NUnit.Framework; + +namespace NHibernate.Test.SystemTransactions +{ + using System.Threading.Tasks; + [TestFixture] + public class SystemTransactionFixtureAsync : SystemTransactionFixtureBase + { + protected override bool UseConnectionOnSystemTransactionPrepare => true; + protected override bool AutoJoinTransaction => true; + + [Test] + public async Task WillNotCrashOnPrepareFailureAsync() + { + IgnoreIfUnsupported(false); + var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + var disposeCalled = false; + try + { + using (var s = OpenSession()) + { + await (s.SaveAsync(new Person { NotNullData = null })); // Cause a SQL not null constraint violation. + } + + tx.Complete(); + disposeCalled = true; + Assert.Throws(tx.Dispose, "Scope disposal has not rollback and throw."); + } + finally + { + if (!disposeCalled) + { + try + { + tx.Dispose(); + } + catch + { + // Ignore, if disposed has not been called, another exception has occurred in the try and + // we should avoid overriding it by the disposal failure. + } + } + } + } + + [Theory] + public async Task CanRollbackTransactionFromScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + using (var s = OpenSession()) + { + await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + // No Complete call for triggering rollback. + } + + AssertNoPersons(); + } + + [Theory] + [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] + public async Task TransactionInsertWithRollBackFromScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new Person(); + await (s.SaveAsync(person)); + + if (explicitFlush) + await (s.FlushAsync()); + } + // No Complete call for triggering rollback. + } + AssertNoPersons(); + } + + [Theory] + [Description(@"Two session in two txscope + (without an explicit NH transaction) + and with a rollback in the second and a rollback outside nh-session-scope.")] + public async Task TransactionInsertLoadWithRollBackFromScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object savedId; + var createdAt = DateTime.Today; + using (var txscope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new Person { CreatedAt = createdAt }; + savedId = await (s.SaveAsync(person)); + + if (explicitFlush) + await (s.FlushAsync()); + } + txscope.Complete(); + } + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = await (s.GetAsync(savedId)); + person.CreatedAt = createdAt.AddMonths(-1); + + if (explicitFlush) + await (s.FlushAsync()); + } + + // No Complete call for triggering rollback. + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(createdAt, (await (s.GetAsync(savedId))).CreatedAt, "Entity update was not rollback-ed."); + } + } + + [Theory] + public async Task CanDeleteItemAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object id; + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + id = await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(1, await (s.Query().CountAsync()), "Entity not found in database."); + } + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + await (s.DeleteAsync(await (s.GetAsync(id)))); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + } + + AssertNoPersons(); + } + + [Theory] + public async Task CanUseSessionWithManyScopesAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (var s = WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + // Acquire the connection + var count = await (s.Query().CountAsync()); + Assert.That(count, Is.EqualTo(0), "Unexpected initial entity count."); + tx.Complete(); + } + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = await (s.Query().CountAsync()); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after committed insert."); + tx.Complete(); + } + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + + // No complete for rollback-ing. + } + + // Do not reuse the session after a rollback, its state does not allow it. + // http://nhibernate.info/doc/nhibernate-reference/manipulatingdata.html#manipulatingdata-endingsession-commit + } + + using (var s = OpenSession()) + { + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = await (s.Query().CountAsync()); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after rollback-ed insert."); + tx.Complete(); + } + } + } + + [Theory] + public async Task CanUseSessionOutsideOfScopeAfterScopeAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (var s = WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + + tx.Complete(); + } + var count = 0; + Assert.DoesNotThrowAsync(async () => count = await (s.Query().CountAsync()), "Failed using the session after scope."); + if (count != 1) + // We are not testing that here, so just issue a warning. Do not use DodgeTransactionCompletionDelayIfRequired + // before previous assert. We want to ascertain the session is usable in any cases. + Assert.Warn("Unexpected entity count: {0} instead of {1}. The transaction seems to have a delayed commit.", count, 1); + } + } + + [Theory] + [Description("Do not fail, but warn in case a delayed after scope disposal commit is made.")] + public async Task DelayedTransactionCompletionAsync(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + for (var i = 1; i <= 10; i++) + { + // Isolation level must be read committed on the control session: reading twice while expecting some data insert + // in between due to a late commit. Repeatable read would block and read uncommitted would see the uncommitted data. + using (var controlSession = OpenSession()) + using (controlSession.BeginTransaction(System.Data.IsolationLevel.ReadCommitted)) + { + // We want to have the control session as ready to query as possible, thus beginning its + // transaction early for acquiring the connection, even if we will not use it before + // below scope completion. + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + await (s.SaveAsync(new Person())); + + if (explicitFlush) + await (s.FlushAsync()); + } + tx.Complete(); + } + + var count = await (controlSession.Query().CountAsync()); + if (count != i) + { + Thread.Sleep(100); + var countSecondTry = await (controlSession.Query().CountAsync()); + Assert.Warn($"Unexpected entity count: {count} instead of {i}. " + + "This may mean current data provider has a delayed commit, occurring after scope disposal. " + + $"After waiting, count is now {countSecondTry}. "); + break; + } + } + } + } + + [Test] + public async Task FlushFromTransactionAppliesToDisposedSharingSessionAsync() + { + IgnoreIfUnsupported(false); + + var flushOrder = new List(); + using (var s = OpenSession(new TestInterceptor(0, flushOrder))) + { + var builder = s.SessionWithOptions().Connection(); + + using (var t = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + var p1 = new Person(); + var p2 = new Person(); + var p3 = new Person(); + var p4 = new Person(); + + using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) + { + if (!AutoJoinTransaction) + s1.JoinTransaction(); + await (s1.SaveAsync(p1)); + } + using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) + { + if (!AutoJoinTransaction) + s2.JoinTransaction(); + await (s2.SaveAsync(p2)); + using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)) + .OpenSession()) + { + if (!AutoJoinTransaction) + s3.JoinTransaction(); + await (s3.SaveAsync(p3)); + } + } + await (s.SaveAsync(p4)); + t.Complete(); + } + } + + Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); + + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + Assert.That(await (s.Query().CountAsync()), Is.EqualTo(4)); + await (t.CommitAsync()); + } + } + + [Test] + public async Task FlushFromTransactionAppliesToSharingSessionAsync() + { + IgnoreIfUnsupported(false); + + var flushOrder = new List(); + using (var s = OpenSession(new TestInterceptor(0, flushOrder))) + { + var builder = s.SessionWithOptions().Connection(); + + using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) + using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) + using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)).OpenSession()) + using (var t = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + { + s.JoinTransaction(); + s1.JoinTransaction(); + s2.JoinTransaction(); + s3.JoinTransaction(); + } + var p1 = new Person(); + var p2 = new Person(); + var p3 = new Person(); + var p4 = new Person(); + await (s1.SaveAsync(p1)); + await (s2.SaveAsync(p2)); + await (s3.SaveAsync(p3)); + await (s.SaveAsync(p4)); + t.Complete(); + } + } + + Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); + + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + Assert.That(await (s.Query().CountAsync()), Is.EqualTo(4)); + await (t.CommitAsync()); + } + } + + // Taken and adjusted from NH1632 When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_level_cache + [Test] + public async Task WhenCommittingItemsAfterSessionDisposalWillAddThemTo2ndLevelCacheAsync() + { + int id; + const string notNullData = "test"; + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = new CacheablePerson { NotNullData = notNullData }; + await (s.SaveAsync(person)); + id = person.Id; + + await (s.FlushAsync()); + } + tx.Complete(); + } + + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + using (var s = OpenSession()) + { + var person = await (s.LoadAsync(id)); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + tx.Complete(); + } + + // Closing the connection to ensure we can't actually use it. + var connection = await (Sfi.ConnectionProvider.GetConnectionAsync(CancellationToken.None)); + Sfi.ConnectionProvider.CloseConnection(connection); + + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. + using (var s = WithOptions().Connection(connection).OpenSession()) + { + CacheablePerson person = null; + Assert.DoesNotThrowAsync(async () => person = await (s.LoadAsync(id)), "Failed loading entity from second level cache."); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + } + + [Test] + public async Task DoNotDeadlockOnAfterTransactionWaitAsync() + { + var interceptor = new AfterTransactionWaitingInterceptor(); + using (var s = WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + await (s.FlushAsync()); + tx.Complete(); + } + Assert.That(interceptor.Exception, Is.Null); + } + + [Test] + public async Task EnforceConnectionUsageRulesOnTransactionCompletionAsync() + { + var interceptor = new TransactionCompleteUsingConnectionInterceptor(); + // Do not invert session and scope, it would cause an expected failure when + // UseConnectionOnSystemTransactionEvents is false, due to the session being closed. + using (var s = WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + await (s.SaveAsync(new Person())); + + await (s.FlushAsync()); + tx.Complete(); + } + + if (UseConnectionOnSystemTransactionPrepare) + { + Assert.That(interceptor.BeforeException, Is.Null); + } + else + { + Assert.That(interceptor.BeforeException, Is.TypeOf()); + } + // Currently always forbidden, whatever UseConnectionOnSystemTransactionEvents. + Assert.That(interceptor.AfterException, Is.TypeOf()); + } + } + + [TestFixture] + public class SystemTransactionWithoutConnectionFromPrepareFixtureAsync : SystemTransactionFixtureAsync + { + protected override bool UseConnectionOnSystemTransactionPrepare => false; + } + + [TestFixture] + public class SystemTransactionWithoutConnectionAutoEnlistmentFixtureAsync : SystemTransactionFixtureAsync + { + protected override void Configure(Configuration configuration) + { + base.Configure(configuration); + DisableConnectionAutoEnlist(configuration); + } + + protected override bool AppliesTo(ISessionFactoryImplementor factory) + => base.AppliesTo(factory) && factory.ConnectionProvider.Driver.SupportsEnlistmentWhenAutoEnlistmentIsDisabled; + } + + [TestFixture] + public class SystemTransactionWithoutAutoJoinTransactionAsync : SystemTransactionWithoutConnectionAutoEnlistmentFixtureAsync + { + protected override bool AutoJoinTransaction => false; + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/Async/SystemTransactions/TransactionFixture.cs b/src/NHibernate.Test/Async/SystemTransactions/TransactionFixture.cs deleted file mode 100644 index 1b0d0103bde..00000000000 --- a/src/NHibernate.Test/Async/SystemTransactions/TransactionFixture.cs +++ /dev/null @@ -1,120 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by AsyncGenerator. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - - -using System.Collections.Generic; -using System.Linq; -using System.Transactions; -using NHibernate.Linq; -using NHibernate.Test.TransactionTest; -using NUnit.Framework; - -namespace NHibernate.Test.SystemTransactions -{ - using System.Threading.Tasks; - [TestFixture] - public class TransactionFixtureAsync : TransactionFixtureBase - { - [Test] - public async Task CanUseSystemTransactionsToCommitAsync() - { - int identifier; - using(ISession session = Sfi.OpenSession()) - using(TransactionScope tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - var s = new Person(); - await (session.SaveAsync(s)); - identifier = s.Id; - tx.Complete(); - } - - using (ISession session = Sfi.OpenSession()) - using (TransactionScope tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - var w = await (session.GetAsync(identifier)); - Assert.IsNotNull(w); - await (session.DeleteAsync(w)); - tx.Complete(); - } - } - - [Test] - public async Task FlushFromTransactionAppliesToDisposedSharingSessionAsync() - { - var flushOrder = new List(); - using (var s = OpenSession(new TestInterceptor(0, flushOrder))) - { - var builder = s.SessionWithOptions().Connection(); - - using (var t = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - var p1 = new Person(); - var p2 = new Person(); - var p3 = new Person(); - var p4 = new Person(); - - using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) - await (s1.SaveAsync(p1)); - using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) - { - await (s2.SaveAsync(p2)); - using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)).OpenSession()) - await (s3.SaveAsync(p3)); - } - await (s.SaveAsync(p4)); - t.Complete(); - } - } - - Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); - - using (var s = OpenSession()) - using (var t = s.BeginTransaction()) - { - Assert.That(await (s.Query().CountAsync()), Is.EqualTo(4)); - await (t.CommitAsync()); - } - } - - [Test] - public async Task FlushFromTransactionAppliesToSharingSessionAsync() - { - var flushOrder = new List(); - using (var s = OpenSession(new TestInterceptor(0, flushOrder))) - { - var builder = s.SessionWithOptions().Connection(); - - using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) - using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) - using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)).OpenSession()) - using (var t = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - var p1 = new Person(); - var p2 = new Person(); - var p3 = new Person(); - var p4 = new Person(); - await (s1.SaveAsync(p1)); - await (s2.SaveAsync(p2)); - await (s3.SaveAsync(p3)); - await (s.SaveAsync(p4)); - t.Complete(); - } - } - - Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); - - using (var s = OpenSession()) - using (var t = s.BeginTransaction()) - { - Assert.That(await (s.Query().CountAsync()), Is.EqualTo(4)); - await (t.CommitAsync()); - } - } - } -} \ No newline at end of file diff --git a/src/NHibernate.Test/Async/SystemTransactions/TransactionNotificationFixture.cs b/src/NHibernate.Test/Async/SystemTransactions/TransactionNotificationFixture.cs deleted file mode 100644 index ddb43294264..00000000000 --- a/src/NHibernate.Test/Async/SystemTransactions/TransactionNotificationFixture.cs +++ /dev/null @@ -1,146 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by AsyncGenerator. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - - -using System; -using System.Collections; -using System.Data.Common; -using System.Threading; -using System.Transactions; -using NUnit.Framework; - -namespace NHibernate.Test.SystemTransactions -{ - using System.Threading.Tasks; - [TestFixture] - public class TransactionNotificationFixtureAsync : TestCase - { - protected override IList Mappings - { - get { return new string[] {}; } - } - - [Test] - public async Task TwoTransactionScopesInsideOneSessionAsync() - { - var interceptor = new RecordingInterceptor(); - using (var session = Sfi.WithOptions().Interceptor(interceptor).OpenSession()) - { - using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - await (session.CreateCriteria().ListAsync()); - scope.Complete(); - } - - using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - await (session.CreateCriteria().ListAsync()); - scope.Complete(); - } - } - Assert.AreEqual(2, interceptor.afterTransactionBeginCalled); - Assert.AreEqual(2, interceptor.beforeTransactionCompletionCalled); - Assert.AreEqual(2, interceptor.afterTransactionCompletionCalled); - } - - [Test] - public async Task OneTransactionScopesInsideOneSessionAsync() - { - var interceptor = new RecordingInterceptor(); - using (var session = Sfi.WithOptions().Interceptor(interceptor).OpenSession()) - { - using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - await (session.CreateCriteria().ListAsync()); - scope.Complete(); - } - } - Assert.AreEqual(1, interceptor.afterTransactionBeginCalled); - Assert.AreEqual(1, interceptor.beforeTransactionCompletionCalled); - Assert.AreEqual(1, interceptor.afterTransactionCompletionCalled); - } - - - [Description("NH2128, NH3572")] - [Theory] - public async Task ShouldNotifyAfterDistributedTransactionAsync(bool doCommit) - { - // Note: For distributed transaction, calling Close() on the session isn't - // supported, so we don't need to test that scenario. - - var interceptor = new RecordingInterceptor(); - ISession s1 = null; - ISession s2 = null; - - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - try - { - s1 = OpenSession(interceptor); - s2 = OpenSession(interceptor); - - await (s1.CreateCriteria().ListAsync()); - await (s2.CreateCriteria().ListAsync()); - } - finally - { - if (s1 != null) - s1.Dispose(); - if (s2 != null) - s2.Dispose(); - } - - if (doCommit) - tx.Complete(); - } - - Assert.That(s1.IsOpen, Is.False); - Assert.That(s2.IsOpen, Is.False); - Assert.That(interceptor.afterTransactionCompletionCalled, Is.EqualTo(2)); - } - - - [Description("NH2128")] - [Theory] - public async Task ShouldNotifyAfterDistributedTransactionWithOwnConnectionAsync(bool doCommit) - { - // Note: For distributed transaction, calling Close() on the session isn't - // supported, so we don't need to test that scenario. - - var interceptor = new RecordingInterceptor(); - ISession s1 = null; - - using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) - { - var ownConnection1 = await (Sfi.ConnectionProvider.GetConnectionAsync(CancellationToken.None)); - - try - { - using (s1 = Sfi.WithOptions().Connection(ownConnection1).Interceptor(interceptor).OpenSession()) - { - await (s1.CreateCriteria().ListAsync()); - } - - if (doCommit) - tx.Complete(); - } - finally - { - Sfi.ConnectionProvider.CloseConnection(ownConnection1); - } - } - - // Transaction completion may happen asynchronously, so allow some delay. - Assert.That(() => s1.IsOpen, Is.False.After(500, 100)); - - Assert.That(interceptor.afterTransactionCompletionCalled, Is.EqualTo(1)); - } - - } -} \ No newline at end of file diff --git a/src/NHibernate.Test/Async/TransactionTest/TransactionFixture.cs b/src/NHibernate.Test/Async/TransactionTest/TransactionFixture.cs index 7c1959abfbf..3974fb25f4f 100644 --- a/src/NHibernate.Test/Async/TransactionTest/TransactionFixture.cs +++ b/src/NHibernate.Test/Async/TransactionTest/TransactionFixture.cs @@ -18,6 +18,7 @@ namespace NHibernate.Test.TransactionTest { using System.Threading.Tasks; + using System.Threading; [TestFixture] public class TransactionFixtureAsync : TransactionFixtureBase { @@ -184,5 +185,45 @@ public async Task FlushFromTransactionAppliesToSharingSessionAsync() await (t.CommitAsync()); } } + + // Taken and adjusted from NH1632 When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_level_cache + [Test] + public async Task WhenCommittingItemsWillAddThemTo2ndLevelCacheAsync() + { + int id; + const string notNullData = "test"; + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + var person = new CacheablePerson { NotNullData = notNullData }; + await (s.SaveAsync(person)); + id = person.Id; + + await (t.CommitAsync()); + } + + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + var person = await (s.LoadAsync(id)); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + await (t.CommitAsync()); + } + + // Closing the connection to ensure we can't actually use it. + var connection = await (Sfi.ConnectionProvider.GetConnectionAsync(CancellationToken.None)); + Sfi.ConnectionProvider.CloseConnection(connection); + + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. + using (var s = Sfi.WithOptions().Connection(connection).OpenSession()) + { + CacheablePerson person = null; + Assert.DoesNotThrowAsync(async () => person = await (s.LoadAsync(id)), "Failed loading entity from second level cache."); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + } } } \ No newline at end of file diff --git a/src/NHibernate.Test/DebugConnectionProvider.cs b/src/NHibernate.Test/DebugConnectionProvider.cs index f84be723860..9f9bdc81e1e 100644 --- a/src/NHibernate.Test/DebugConnectionProvider.cs +++ b/src/NHibernate.Test/DebugConnectionProvider.cs @@ -37,13 +37,17 @@ public override void CloseConnection(DbConnection conn) } public bool HasOpenConnections + => connections.Keys.Any(IsNotClosed); + + private static bool IsNotClosed(DbConnection conn) { - get + try + { + return conn.State != ConnectionState.Closed; + } + catch (ObjectDisposedException) { - // Disposing of an ISession does not call CloseConnection (should it???) - // so a Diposed of ISession will leave an DbConnection in the list but - // the DbConnection will be closed (atleast with MsSql it works this way). - return connections.Keys.Any(conn => conn.State != ConnectionState.Closed); + return false; } } diff --git a/src/NHibernate.Test/DebugSessionFactory.cs b/src/NHibernate.Test/DebugSessionFactory.cs index cfac633e805..943ab97f296 100644 --- a/src/NHibernate.Test/DebugSessionFactory.cs +++ b/src/NHibernate.Test/DebugSessionFactory.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Data.Common; using System.Linq; -using System.Threading; using log4net; using NHibernate.Cache; using NHibernate.Cfg; @@ -74,20 +73,11 @@ public bool CheckSessionsWereClosed() private bool CheckSessionWasClosed(ISessionImplementor session) { + session.TransactionContext?.Wait(); + if (!session.IsOpen) return true; - if (session.TransactionContext?.ShouldCloseSessionOnDistributedTransactionCompleted ?? false) - { - // Delayed transactions not having completed and closed their sessions? Give them a chance to complete. - Thread.Sleep(100); - if (!session.IsOpen) - { - _log.Warn($"Test case had a delayed close of session {session.SessionId}."); - return true; - } - } - _log.Error($"Test case didn't close session {session.SessionId}, closing"); (session as ISession)?.Close(); (session as IStatelessSession)?.Close(); @@ -451,6 +441,12 @@ ISessionBuilder ISessionBuilder.AutoClose(bool autoClose) return this; } + ISessionBuilder ISessionBuilder.AutoJoinTransaction(bool autoJoinTransaction) + { + _actualBuilder.AutoJoinTransaction(autoJoinTransaction); + return this; + } + ISessionBuilder ISessionBuilder.FlushMode(FlushMode flushMode) { _actualBuilder.FlushMode(flushMode); @@ -488,6 +484,12 @@ IStatelessSessionBuilder IStatelessSessionBuilder.Connection(DbConnection connec return this; } + IStatelessSessionBuilder IStatelessSessionBuilder.AutoJoinTransaction(bool autoJoinTransaction) + { + _actualBuilder.AutoJoinTransaction(autoJoinTransaction); + return this; + } + #endregion } } diff --git a/src/NHibernate.Test/DynamicProxyTests/PeVerifier.cs b/src/NHibernate.Test/DynamicProxyTests/PeVerifier.cs index eb323566b5b..0a0487be2d1 100644 --- a/src/NHibernate.Test/DynamicProxyTests/PeVerifier.cs +++ b/src/NHibernate.Test/DynamicProxyTests/PeVerifier.cs @@ -7,7 +7,7 @@ namespace NHibernate.Test.DynamicProxyTests { // utility class to run PEVerify.exe against a saved-to-disk assembly, similar to: // http://stackoverflow.com/questions/7290893/is-there-an-api-for-verifying-the-msil-of-a-dynamic-assembly-at-runtime - public class PeVerifier + public partial class PeVerifier { private string _assemlyLocation; private string _peVerifyPath; diff --git a/src/NHibernate.Test/NHSpecificTest/DtcFailures/DtcFailuresFixture.cs b/src/NHibernate.Test/NHSpecificTest/DtcFailures/DtcFailuresFixture.cs deleted file mode 100644 index f870a1ae488..00000000000 --- a/src/NHibernate.Test/NHSpecificTest/DtcFailures/DtcFailuresFixture.cs +++ /dev/null @@ -1,357 +0,0 @@ -using System; -using System.Collections; -using System.Linq; -using System.Reflection; -using System.Threading; -using System.Transactions; -using log4net; -using log4net.Repository.Hierarchy; -using NHibernate.Cfg; -using NHibernate.Cfg.MappingSchema; -using NHibernate.Dialect; -using NHibernate.Tool.hbm2ddl; -using NUnit.Framework; - -namespace NHibernate.Test.NHSpecificTest.DtcFailures -{ - [TestFixture] - public class DtcFailuresFixture : TestCase - { - private static readonly ILog log = LogManager.GetLogger(typeof(DtcFailuresFixture)); - - protected override IList Mappings - { - get { return new[] {"NHSpecificTest.DtcFailures.Mappings.hbm.xml"}; } - } - - protected override string MappingsAssembly - { - get { return "NHibernate.Test"; } - } - - protected override bool AppliesTo(Dialect.Dialect dialect) - { - return TestDialect.GetTestDialect(dialect).SupportsDistributedTransactions; - } - - protected override void CreateSchema() - { - // Copied from Configure method. - Configuration config = new Configuration(); - if (TestConfigurationHelper.hibernateConfigFile != null) - config.Configure(TestConfigurationHelper.hibernateConfigFile); - - // Our override so we can set nullability on database column without NHibernate knowing about it. - config.BeforeBindMapping += BeforeBindMapping; - - // Copied from AddMappings methods. - Assembly assembly = Assembly.Load(MappingsAssembly); - foreach (string file in Mappings) - config.AddResource(MappingsAssembly + "." + file, assembly); - - // Copied from CreateSchema method, but we use our own config. - new SchemaExport(config).Create(false, true); - } - - private void BeforeBindMapping(object sender, BindMappingEventArgs e) - { - HbmProperty prop = e.Mapping.RootClasses[0].Properties.OfType().Single(p => p.Name == "NotNullData"); - prop.notnull = true; - prop.notnullSpecified = true; - } - - [Test] - public void WillNotCrashOnDtcPrepareFailure() - { - var tx = new TransactionScope(); - using (ISession s = OpenSession()) - { - s.Save(new Person {NotNullData = null}); // Cause a SQL not null constraint violation. - } - - new ForceEscalationToDistributedTx(); - - tx.Complete(); - try - { - tx.Dispose(); - Assert.Fail("Expected failure"); - } - catch (AssertionException) - { - throw; - } - catch (Exception) {} - } - - [Test] - public void Can_roll_back_transaction() - { - var tx = new TransactionScope(); - using (ISession s = OpenSession()) - { - new ForceEscalationToDistributedTx(true); //will rollback tx - s.Save(new Person { CreatedAt = DateTime.Today }); - - tx.Complete(); - } - try - { - tx.Dispose(); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - //expected - } - } - - [Test] - [Description("Another action inside the transaction do the rollBack outside nh-session-scope.")] - public void RollbackOutsideNh() - { - try - { - using (var txscope = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - var person = new Person { CreatedAt = DateTime.Now }; - s.Save(person); - } - new ForceEscalationToDistributedTx(true); //will rollback tx - - txscope.Complete(); - } - - log.DebugFormat("Transaction fail."); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - log.DebugFormat("Transaction aborted."); - } - } - - [Test] - [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] - public void TransactionInsertWithRollBackTask() - { - try - { - using (var txscope = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - var person = new Person {CreatedAt = DateTime.Now}; - s.Save(person); - new ForceEscalationToDistributedTx(true); //will rollback tx - person.CreatedAt = DateTime.Now; - s.Update(person); - } - txscope.Complete(); - } - log.DebugFormat("Transaction fail."); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - log.DebugFormat("Transaction aborted."); - } - } - - [Test, Ignore("Not fixed.")] - [Description(@"Two session in two txscope -(without an explicit NH transaction and without an explicit flush) -and with a rollback in the second dtc and a ForceRollback outside nh-session-scope.")] - public void TransactionInsertLoadWithRollBackTask() - { - object savedId; - using (var txscope = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - var person = new Person {CreatedAt = DateTime.Now}; - savedId = s.Save(person); - } - txscope.Complete(); - } - try - { - using (var txscope = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - var person = s.Get(savedId); - person.CreatedAt = DateTime.Now; - s.Update(person); - } - new ForceEscalationToDistributedTx(true); - - log.Debug("completing the tx scope"); - txscope.Complete(); - } - log.Debug("Transaction fail."); - Assert.Fail("Expected tx abort"); - } - catch (TransactionAbortedException) - { - log.Debug("Transaction aborted."); - } - finally - { - using (var txscope = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - var person = s.Get(savedId); - s.Delete(person); - } - txscope.Complete(); - } - } - } - - private int totalCall; - - [Test, Explicit] - public void MultiThreadedTransaction() - { - // Test added for NH-1709 (trying to recreate the issue... without luck) - // If one thread break the test, you can see the result in the console. - ((Logger)log.Logger).Level = log4net.Core.Level.Debug; - var actions = new MultiThreadRunner.ExecuteAction[] - { - delegate(object o) - { - Can_roll_back_transaction(); - totalCall++; - }, - delegate(object o) - { - RollbackOutsideNh(); - totalCall++; - }, - delegate(object o) - { - TransactionInsertWithRollBackTask(); - totalCall++; - }, - //delegate(object o) - // { - // TransactionInsertLoadWithRollBackTask(); - // totalCall++; - // }, - }; - var mtr = new MultiThreadRunner(20, actions) - { - EndTimeout = 5000, TimeoutBetweenThreadStart = 5 - }; - mtr.Run(null); - log.DebugFormat("{0} calls", totalCall); - } - - [Test] - public void CanDeleteItemInDtc() - { - object id; - using (var tx = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - id = s.Save(new Person {CreatedAt = DateTime.Today}); - - new ForceEscalationToDistributedTx(); - - tx.Complete(); - } - } - - // Dodging "latency" due to db still haven't actually committed a distributed tx after scope disposal. - Thread.Sleep(100); - - using (var tx = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - new ForceEscalationToDistributedTx(); - - s.Delete(s.Get(id)); - - tx.Complete(); - } - } - - // Dodging "latency" due to db still haven't actually committed a distributed tx after scope disposal. - Thread.Sleep(100); - } - - [Test] - [Description("Open/Close a session inside a TransactionScope fails.")] - public void NH1744() - { - using (var tx = new TransactionScope()) - { - using (ISession s = OpenSession()) - { - s.Flush(); - } - - using (ISession s = OpenSession()) - { - s.Flush(); - } - - //and I always leave the transaction disposed without calling tx.Complete(), I let the database server to rollback all actions in this test. - } - } - - public class ForceEscalationToDistributedTx : IEnlistmentNotification - { - private readonly bool shouldRollBack; - private readonly int thread; - - public ForceEscalationToDistributedTx(bool shouldRollBack) - { - this.shouldRollBack = shouldRollBack; - thread = Thread.CurrentThread.ManagedThreadId; - System.Transactions.Transaction.Current.EnlistDurable(Guid.NewGuid(), this, EnlistmentOptions.None); - } - - public ForceEscalationToDistributedTx() : this(false) {} - - public void Prepare(PreparingEnlistment preparingEnlistment) - { - if (thread == Thread.CurrentThread.ManagedThreadId) - { - log.Warn("Thread.CurrentThread.ManagedThreadId ({0}) is same as creation thread"); - } - - if (shouldRollBack) - { - log.Debug(">>>>Force Rollback<<<<<"); - preparingEnlistment.ForceRollback(); - } - else - { - preparingEnlistment.Prepared(); - } - } - - public void Commit(Enlistment enlistment) - { - enlistment.Done(); - } - - public void Rollback(Enlistment enlistment) - { - enlistment.Done(); - } - - public void InDoubt(Enlistment enlistment) - { - enlistment.Done(); - } - } - } -} \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/DtcFailures/Mappings.hbm.xml b/src/NHibernate.Test/NHSpecificTest/DtcFailures/Mappings.hbm.xml deleted file mode 100644 index e565237ce69..00000000000 --- a/src/NHibernate.Test/NHSpecificTest/DtcFailures/Mappings.hbm.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - diff --git a/src/NHibernate.Test/NHSpecificTest/DtcFailures/Person.cs b/src/NHibernate.Test/NHSpecificTest/DtcFailures/Person.cs deleted file mode 100644 index f770d6570d8..00000000000 --- a/src/NHibernate.Test/NHSpecificTest/DtcFailures/Person.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using System; - -namespace NHibernate.Test.NHSpecificTest.DtcFailures -{ - public class Person - { - private int id; - - public Person() - { - NotNullData = "not-null"; - } - - public virtual DateTime CreatedAt { get; set; } - - public virtual int Id - { - get { return id; } - set { id = value; } - } - - public virtual string NotNullData { get; set; } - } -} \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/NH1054/DummyTransactionFactory.cs b/src/NHibernate.Test/NHSpecificTest/NH1054/DummyTransactionFactory.cs index d41491e480e..c00a08a9575 100644 --- a/src/NHibernate.Test/NHSpecificTest/NH1054/DummyTransactionFactory.cs +++ b/src/NHibernate.Test/NHSpecificTest/NH1054/DummyTransactionFactory.cs @@ -1,6 +1,5 @@ using System; -using System.Collections; -using NHibernate.AdoNet; +using System.Collections.Generic; using NHibernate.Engine; using NHibernate.Engine.Transaction; using NHibernate.Transaction; @@ -9,7 +8,7 @@ namespace NHibernate.Test.NHSpecificTest.NH1054 { public partial class DummyTransactionFactory : ITransactionFactory { - public void Configure(IDictionary props) + public void Configure(IDictionary props) { } @@ -18,12 +17,17 @@ public ITransaction CreateTransaction(ISessionImplementor session) throw new NotImplementedException(); } - public void EnlistInDistributedTransactionIfNeeded(ISessionImplementor session) + public void EnlistInSystemTransactionIfNeeded(ISessionImplementor session) { throw new NotImplementedException(); } - public bool IsInDistributedActiveTransaction(ISessionImplementor session) + public void ExplicitJoinSystemTransaction(ISessionImplementor session) + { + throw new NotImplementedException(); + } + + public bool IsInActiveSystemTransaction(ISessionImplementor session) { return false; } diff --git a/src/NHibernate.Test/NHSpecificTest/NH1054/NH1054Fixture.cs b/src/NHibernate.Test/NHSpecificTest/NH1054/NH1054Fixture.cs index 552ffd1e48b..94e4919572a 100644 --- a/src/NHibernate.Test/NHSpecificTest/NH1054/NH1054Fixture.cs +++ b/src/NHibernate.Test/NHSpecificTest/NH1054/NH1054Fixture.cs @@ -26,7 +26,7 @@ public void AdoNetWithDistributedTransactionFactoryIsDefaultTransactionFactory() Configuration configuration = new Configuration(); ISessionFactoryImplementor sessionFactory = (ISessionFactoryImplementor)configuration.BuildSessionFactory(); - Assert.That(sessionFactory.Settings.TransactionFactory, Is.InstanceOf()); + Assert.That(sessionFactory.Settings.TransactionFactory, Is.InstanceOf()); } } } diff --git a/src/NHibernate.Test/NHSpecificTest/NH1632/Fixture.cs b/src/NHibernate.Test/NHSpecificTest/NH1632/Fixture.cs index ef2bd66b359..6d56dfefd34 100644 --- a/src/NHibernate.Test/NHSpecificTest/NH1632/Fixture.cs +++ b/src/NHibernate.Test/NHSpecificTest/NH1632/Fixture.cs @@ -1,3 +1,4 @@ +using System.Data; using NUnit.Framework; namespace NHibernate.Test.NHSpecificTest.NH1632 @@ -20,12 +21,15 @@ protected override void Configure(Configuration configuration) { configuration .SetProperty(Environment.UseSecondLevelCache, "true") - .SetProperty(Environment.CacheProvider, typeof (HashtableCacheProvider).AssemblyQualifiedName); + .SetProperty(Environment.CacheProvider, typeof(HashtableCacheProvider).AssemblyQualifiedName); } [Test] public void When_using_DTC_HiLo_knows_to_create_isolated_DTC_transaction() { + if (!Dialect.SupportsConcurrentWritingConnections) + Assert.Ignore(Dialect.GetType().Name + " does not support concurrent writing connections, can not isolate work."); + object scalar1, scalar2; using (var session = Sfi.OpenSession()) @@ -35,18 +39,19 @@ public void When_using_DTC_HiLo_knows_to_create_isolated_DTC_transaction() scalar1 = command.ExecuteScalar(); } - using (var tx = new TransactionScope()) + using (new TransactionScope()) { var generator = Sfi.GetIdentifierGenerator(typeof(Person).FullName); Assert.That(generator, Is.InstanceOf()); - using(var session = Sfi.OpenSession()) + using (var session = OpenSession()) { - var id = generator.Generate((ISessionImplementor) session, new Person()); + // Force connection acquisition for having it enlisted. + Assert.That(session.Connection.State, Is.EqualTo(ConnectionState.Open)); + generator.Generate((ISessionImplementor)session, new Person()); } // intentionally dispose without committing - tx.Dispose(); } using (var session = Sfi.OpenSession()) @@ -56,7 +61,7 @@ public void When_using_DTC_HiLo_knows_to_create_isolated_DTC_transaction() scalar2 = command.ExecuteScalar(); } - Assert.AreNotEqual(scalar1, scalar2,"HiLo must run with in its own transaction"); + Assert.AreNotEqual(scalar1, scalar2, "HiLo must run with in its own transaction"); } [Test] @@ -84,43 +89,49 @@ public void When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_level_ { using (var s = Sfi.OpenSession()) { - s.Save(new Nums {ID = 29, NumA = 1, NumB = 3}); + s.Save(new Nums { ID = 29, NumA = 1, NumB = 3 }); } tx.Complete(); } - - using (var tx = new TransactionScope()) + try { - using (var s = Sfi.OpenSession()) + + using (var tx = new TransactionScope()) { - var nums = s.Load(29); - Assert.AreEqual(1, nums.NumA); - Assert.AreEqual(3, nums.NumB); + using (var s = OpenSession()) + { + var nums = s.Load(29); + Assert.AreEqual(1, nums.NumA); + Assert.AreEqual(3, nums.NumB); + } + tx.Complete(); } - tx.Complete(); - } - //closing the connection to ensure we can't really use it. - var connection = Sfi.ConnectionProvider.GetConnection(); - Sfi.ConnectionProvider.CloseConnection(connection); + //closing the connection to ensure we can't really use it. + var connection = Sfi.ConnectionProvider.GetConnection(); + Sfi.ConnectionProvider.CloseConnection(connection); - using (var tx = new TransactionScope()) - { + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. using (var s = Sfi.WithOptions().Connection(connection).OpenSession()) { - var nums = s.Load(29); + Nums nums = null; + Assert.DoesNotThrow(() => nums = s.Load(29), "Failed loading entity from second level cache."); Assert.AreEqual(1, nums.NumA); Assert.AreEqual(3, nums.NumB); } - tx.Complete(); } - - using (var s = Sfi.OpenSession()) - using (var tx = s.BeginTransaction()) + finally { - var nums = s.Load(29); - s.Delete(nums); - tx.Commit(); + using (var s = OpenSession()) + using (var tx = s.BeginTransaction()) + { + var nums = s.Load(29); + s.Delete(nums); + tx.Commit(); + } } } @@ -174,9 +185,9 @@ public void Will_not_save_when_flush_mode_is_never() [Test] public void When_using_two_sessions_with_explicit_flush() { - if (!TestDialect.SupportsConcurrentTransactions) - Assert.Ignore(Dialect.GetType().Name + " does not support concurrent transactions."); - if (!TestDialect.SupportsDistributedTransactions) + if (!Dialect.SupportsConcurrentWritingConnectionsInSameTransaction) + Assert.Ignore(Dialect.GetType().Name + " does not support concurrent connections in same transaction."); + if (!Dialect.SupportsDistributedTransactions) Assert.Ignore(Dialect.GetType().Name + " does not support distributed transactions."); object id1, id2; @@ -214,9 +225,9 @@ public void When_using_two_sessions_with_explicit_flush() [Test] public void When_using_two_sessions() { - if (!TestDialect.SupportsConcurrentTransactions) - Assert.Ignore(Dialect.GetType().Name + " does not support concurrent transactions."); - if (!TestDialect.SupportsDistributedTransactions) + if (!Dialect.SupportsConcurrentWritingConnectionsInSameTransaction) + Assert.Ignore(Dialect.GetType().Name + " does not support concurrent connections in same transaction."); + if (!Dialect.SupportsDistributedTransactions) Assert.Ignore(Dialect.GetType().Name + " does not support distributed transactions."); object id1, id2; diff --git a/src/NHibernate.Test/NHSpecificTest/NH2176/Fixture.cs b/src/NHibernate.Test/NHSpecificTest/NH2176/Fixture.cs new file mode 100644 index 00000000000..2f5cabedad4 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH2176/Fixture.cs @@ -0,0 +1,76 @@ +using System; +using System.Transactions; +using NUnit.Framework; + +namespace NHibernate.Test.NHSpecificTest.NH2176 +{ + [TestFixture] + public class Fixture : BugTestCase + { + protected override void OnSetUp() + { + base.OnSetUp(); + using (var s = OpenSession()) + using (var tx = s.BeginTransaction()) + { + var steve = new Person { Name = "Steve" }; + var peter = new Person { Name = "Peter" }; + var simon = new Person { Name = "Simon" }; + var paul = new Person { Name = "Paul" }; + var john = new Person { Name = "John" }; + var eric = new Person { Name = "Eric" }; + + s.Save(steve); + s.Save(peter); + s.Save(simon); + s.Save(paul); + s.Save(john); + s.Save(eric); + + tx.Commit(); + } + } + + protected override void OnTearDown() + { + base.OnTearDown(); + using (var s = OpenSession()) + using (var tx = s.BeginTransaction()) + { + s.Delete("from Person"); + tx.Commit(); + } + } + + // Whilst this bug seems specific to Oracle I think it is valid to run the + // test against all database types. + [Test] + public void MultipleConsecutiveTransactionScopesCanBeUsedInsideASingleSession() + { + using (var s = OpenSession()) + { + // usually fails after just a few loops in oracle + // this can be run for 10000 loops in sql server without problem + for (var i = 0; i < 100; ++i) + { + Console.WriteLine(i.ToString()); + + using (var scope = new TransactionScope()) + { + var criteria = s.CreateCriteria(); + var people = criteria.List(); + + Assert.That(people.Count, Is.EqualTo(6)); + + scope.Complete(); + } + + // The exception is caused by a race condition between two threads. + // This can be demonstrated by uncommenting the following line which + // causes the test to run without an exception. + //System.Threading.Thread.Sleep(1000); + } + } + } + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/NH2176/Mappings.hbm.xml b/src/NHibernate.Test/NHSpecificTest/NH2176/Mappings.hbm.xml new file mode 100644 index 00000000000..de1353fe4e2 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH2176/Mappings.hbm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/NH2176/Model.cs b/src/NHibernate.Test/NHSpecificTest/NH2176/Model.cs new file mode 100644 index 00000000000..f730029b2a6 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH2176/Model.cs @@ -0,0 +1,8 @@ +namespace NHibernate.Test.NHSpecificTest.NH2176 +{ + public class Person + { + public virtual int Id { get; set; } + public virtual string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/NH2420/Fixture.cs b/src/NHibernate.Test/NHSpecificTest/NH2420/Fixture.cs index e868a7a8685..0cde5572e99 100644 --- a/src/NHibernate.Test/NHSpecificTest/NH2420/Fixture.cs +++ b/src/NHibernate.Test/NHSpecificTest/NH2420/Fixture.cs @@ -1,12 +1,10 @@ -using System; -using System.Data.Common; +using System.Data.Common; using System.Data.Odbc; using System.Data.SqlClient; using System.Configuration; using System.Transactions; using NHibernate.Dialect; using NHibernate.Driver; -using NHibernate.Engine; using NUnit.Framework; using Environment = NHibernate.Cfg.Environment; @@ -55,44 +53,59 @@ public void ShouldBeAbleToReleaseSuppliedConnectionAfterDistributedTransaction() { string connectionString = FetchConnectionStringFromConfiguration(); ISession s; - using (var ts = new TransactionScope()) + DbConnection connection = null; + try { - // Enlisting DummyEnlistment as a durable resource manager will start - // a DTC transaction - System.Transactions.Transaction.Current.EnlistDurable( - DummyEnlistment.Id, - new DummyEnlistment(), - EnlistmentOptions.None); + using (var ts = new TransactionScope()) + { + // Enlisting DummyEnlistment as a durable resource manager will start + // a DTC transaction + System.Transactions.Transaction.Current.EnlistDurable( + DummyEnlistment.Id, + new DummyEnlistment(), + EnlistmentOptions.None); - DbConnection connection; - if (Sfi.ConnectionProvider.Driver.GetType() == typeof(OdbcDriver)) - connection = new OdbcConnection(connectionString); - else - connection = new SqlConnection(connectionString); + if (Sfi.ConnectionProvider.Driver.GetType() == typeof(OdbcDriver)) + connection = new OdbcConnection(connectionString); + else + connection = new SqlConnection(connectionString); - using (connection) - { connection.Open(); using (s = Sfi.WithOptions().Connection(connection).OpenSession()) { s.Save(new MyTable { String = "hello!" }); } - connection.Close(); - } + // The ts disposal may try to flush the session, which, depending on the native generator + // implementation for current dialect, may have something to do and will then try to use + // the supplied connection. dispose connection here => flaky test, failing for dialects + // not mandating an immediate insert on native generator save. + // Delaying the connection disposal to after ts disposal. - ts.Complete(); + ts.Complete(); + } + } + finally + { + connection?.Dispose(); } + // It appears neither the second phase of the 2PC nor TransactionCompleted + // event are guaranteed to be executed before exiting transaction scope disposal. + // When having only 2PC, the second phase tends to occur after reaching that point + // here. When having TransactionCompleted event, this event and the second phase + // tend to occur before reaching here. But some other NH cases demonstrate that + // TransactionCompleted may also occur "too late". + s.GetSessionImplementation().TransactionContext?.Wait(); + // Prior to the patch, an InvalidOperationException exception would occur in the // TransactionCompleted delegate at this point with the message, "Disconnect cannot // be called while a transaction is in progress". Although the exception can be // seen reported in the IDE, NUnit fails to see it. The TransactionCompleted event // fires *after* the transaction is committed and so it doesn't affect the success // of the transaction. - Assert.That(s.IsConnected, Is.False); - Assert.That(((ISessionImplementor)s).ConnectionManager.IsConnected, Is.False); - Assert.That(((ISessionImplementor)s).IsClosed, Is.True); + Assert.That(s.GetSessionImplementation().ConnectionManager.IsConnected, Is.False); + Assert.That(s.GetSessionImplementation().IsClosed, Is.True); } protected override void OnTearDown() diff --git a/src/NHibernate.Test/NHSpecificTest/NH3023/DeadlockConnectionPoolIssueTest.cs b/src/NHibernate.Test/NHSpecificTest/NH3023/DeadlockConnectionPoolIssueTest.cs new file mode 100644 index 00000000000..a1c1464a17d --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH3023/DeadlockConnectionPoolIssueTest.cs @@ -0,0 +1,299 @@ +using System; +using System.Data.SqlClient; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Transactions; +using log4net; +using log4net.Repository.Hierarchy; +using NHibernate.Cfg; +using NHibernate.Dialect; +using NHibernate.Driver; +using NHibernate.Engine; +using NUnit.Framework; + +namespace NHibernate.Test.NHSpecificTest.NH3023 +{ + [TestFixture] + public class DeadlockConnectionPoolIssue : BugTestCase + { + private static readonly ILog _log = LogManager.GetLogger(typeof(DeadlockConnectionPoolIssue)); + + protected virtual bool UseConnectionOnSystemTransactionPrepare => true; + + protected override void Configure(Configuration configuration) + { + configuration.SetProperty( + Cfg.Environment.UseConnectionOnSystemTransactionPrepare, + UseConnectionOnSystemTransactionPrepare.ToString()); + } + + // Uses directly SqlConnection. + protected override bool AppliesTo(ISessionFactoryImplementor factory) + => factory.ConnectionProvider.Driver is SqlClientDriver && base.AppliesTo(factory); + + protected override bool AppliesTo(Dialect.Dialect dialect) + => dialect is MsSql2000Dialect && base.AppliesTo(dialect); + + protected override void OnSetUp() + { + RunScript("db-seed.sql"); + + ((Logger)_log.Logger).Level = log4net.Core.Level.Debug; + } + + protected override void OnTearDown() + { + // Before clearing the pool for dodging pool corruption, we need to wait + // for late transaction processing not yet ended. + Thread.Sleep(100); + // + // Hopefully this will clean up the pool so that teardown can succeed + // + SqlConnection.ClearAllPools(); + + RunScript("db-teardown.sql"); + + using (var s = OpenSession()) + { + s.CreateQuery("delete from System.Object").ExecuteUpdate(); + } + } + + [Theory] + public void ConnectionPoolCorruptionAfterDeadlock(bool distributed, bool disposeSessionBeforeScope) + { + var tryCount = 0; + var id = 1; + do + { + tryCount++; + var missingDeadlock = false; + + try + { + _log.DebugFormat("Starting loop {0}", tryCount); + // When the connection is released from transaction completion, the scope disposal after deadlock + // takes up to 30 seconds (not at first try, but at subsequent tries). With additional logs, it + // appears this delay occurs at connection closing. Definitely, there is something which can go + // wrong when disposing a connection from transaction scope completion. + // Note that the transaction completion event can execute as soon as the deadlock occurs. It does + // not wait for the scope disposal. + var session = OpenSession(); + var scope = distributed ? CreateDistributedTransactionScope() : new TransactionScope(); + try + { + _log.Debug("Session and scope opened"); + session.GetSessionImplementation().Factory.TransactionFactory + .EnlistInSystemTransactionIfNeeded(session.GetSessionImplementation()); + _log.Debug("Session enlisted"); + try + { + new DeadlockHelper().ForceDeadlockOnConnection((SqlConnection)session.Connection); + } + catch (SqlException x) + { + // + // Deadlock error code is 1205. + // + if (x.Errors.Cast().Any(e => e.Number == 1205)) + { + // + // It did what it was supposed to do. + // + _log.InfoFormat("Expected deadlock on attempt {0}. {1}", tryCount, x.Message); + continue; + } + + // + // ? This shouldn't happen + // + Assert.Fail("Surprising exception when trying to force a deadlock: {0}", x); + } + + _log.WarnFormat("Initial session seemingly not deadlocked at attempt {0}", tryCount); + missingDeadlock = true; + + try + { + session.Save( + new DomainClass + { + Id = id++, + ByteData = new byte[] {1, 2, 3} + }); + + session.Flush(); + if (tryCount < 10) + { + _log.InfoFormat("Initial session still usable, trying again"); + continue; + } + _log.InfoFormat("Initial session still usable after {0} attempts, finishing test", tryCount); + } + catch (Exception ex) + { + _log.Error("Failed to continue using the session after lacking deadlock.", ex); + // This exception would hide the transaction failure, if any. + //throw; + } + _log.Debug("Completing scope"); + scope.Complete(); + _log.Debug("Scope completed"); + } + finally + { + // Check who takes time in the disposing + var chrono = new Stopwatch(); + if (disposeSessionBeforeScope) + { + try + { + chrono.Start(); + session.Dispose(); + _log.Debug("Session disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal session disposal duration"); + } + catch (Exception ex) + { + // Log in case it gets hidden by the next finally + _log.Warn("Session disposal failure", ex); + throw; + } + finally + { + chrono.Restart(); + scope.Dispose(); + _log.Debug("Scope disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal scope disposal duration"); + } + } + else + { + try + { + chrono.Start(); + scope.Dispose(); + _log.Debug("Scope disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal scope disposal duration"); + } + catch (Exception ex) + { + // Log in case it gets hidden by the next finally + _log.Warn("Scope disposal failure", ex); + throw; + } + finally + { + chrono.Restart(); + session.Dispose(); + _log.Debug("Session disposed"); + Assert.That(chrono.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "Abnormal session disposal duration"); + } + } + } + _log.Debug("Session and scope disposed"); + } + catch (AssertionException) + { + throw; + } + catch (Exception x) + { + _log.Error($"Initial session failed at attempt {tryCount}.", x); + } + + var subsequentFailedRequests = 0; + + for (var i = 1; i <= 10; i++) + { + // + // The error message will vary on subsequent requests, so we'll somewhat + // arbitrarily try 10 + // + + try + { + using (var scope = new TransactionScope()) + { + using (var session = OpenSession()) + { + session.Save( + new DomainClass + { + Id = id++, + ByteData = new byte[] { 1, 2, 3 } + }); + + session.Flush(); + } + + scope.Complete(); + } + } + catch (Exception x) + { + subsequentFailedRequests++; + _log.Error($"Subsequent session {i} failed.", x); + } + } + + Assert.Fail("{0}; {1} subsequent requests failed.", + missingDeadlock + ? "Deadlock not reported on initial request, and initial request failed" + : "Initial request failed", + subsequentFailedRequests); + + } while (tryCount < 3); + // + // I'll change this to while(true) sometimes so I don't have to keep running the test + // + } + + private static TransactionScope CreateDistributedTransactionScope() + { + var scope = new TransactionScope(); + // + // Forces promotion to distributed transaction + // + TransactionInterop.GetTransmitterPropagationToken(System.Transactions.Transaction.Current); + return scope; + } + + private void RunScript(string script) + { + var cxnString = cfg.Properties["connection.connection_string"] + "; Pooling=No"; + // Disable connection pooling so this won't be hindered by + // problems encountered during the actual test + + string sql; + using (var reader = new StreamReader(GetType().Assembly.GetManifestResourceStream(GetType().Namespace + "." + script))) + { + sql = reader.ReadToEnd(); + } + + using (var cxn = new SqlConnection(cxnString)) + { + cxn.Open(); + + foreach (var batch in Regex.Split(sql, @"^go\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline) + .Where(b => !string.IsNullOrEmpty(b))) + { + + using (var cmd = new System.Data.SqlClient.SqlCommand(batch, cxn)) + { + cmd.ExecuteNonQuery(); + } + } + } + } + } + + [TestFixture] + public class DeadlockConnectionPoolIssueWithoutConnectionFromPrepare : DeadlockConnectionPoolIssue + { + protected override bool UseConnectionOnSystemTransactionPrepare => false; + } +} diff --git a/src/NHibernate.Test/NHSpecificTest/NH3023/DeadlockHelper.cs b/src/NHibernate.Test/NHSpecificTest/NH3023/DeadlockHelper.cs new file mode 100644 index 00000000000..9f744994b97 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH3023/DeadlockHelper.cs @@ -0,0 +1,112 @@ +using System; +using System.Data.SqlClient; +using System.Threading; +using System.Transactions; +using log4net; + +namespace NHibernate.Test.NHSpecificTest.NH3023 +{ + public partial class DeadlockHelper + { + private static readonly ILog _log = LogManager.GetLogger(typeof(DeadlockHelper)); + + public void ForceDeadlockOnConnection(SqlConnection connection) + { + using (var victimLock = new SemaphoreSlim(0)) + using (var winnerLock = new SemaphoreSlim(0)) + { + // + // Second thread with non-pooled connection, to deadlock + // with current thread + // + Exception winnerEx = null; + var winnerThread = new Thread( + () => + { + try + { + using (var scope = new TransactionScope(TransactionScopeOption.RequiresNew)) + { + using (var cxn = new SqlConnection(connection.ConnectionString + ";Pooling=No")) + { + cxn.Open(); + DeadlockParticipant(cxn, false, winnerLock, victimLock); + } + scope.Complete(); + } + } + catch (Exception ex) + { + winnerEx = ex; + } + }); + + winnerThread.Start(); + + try + { + // + // This should always throw an exception of the form + // Transaction (Process ID nn) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction. + // + DeadlockParticipant(connection, true, victimLock, winnerLock); + } + finally + { + winnerThread.Join(); + if (winnerEx != null) + _log.Warn("Winner thread failed", winnerEx); + } + + // + // Should never get here + // + _log.Warn("Expected a deadlock exception for victim, but it was not raised."); + } + } + + private static void DeadlockParticipant(SqlConnection connection, bool isVictim, SemaphoreSlim myLock, SemaphoreSlim partnerLock) + { + try + { + // + // CLID = 1 has only 10 records, CLID = 3 has 100. This guarantees + // which process will be chosen as the victim (the one which will have + // less work to rollback) + // + var clid = isVictim ? 1 : 3; + using (var cmd = new System.Data.SqlClient.SqlCommand("UPDATE DeadlockHelper SET Data = newid() WHERE CLId = @CLID", connection)) + { + // + // Exclusive lock on some records in the table + // + cmd.Parameters.AddWithValue("@CLID", clid); + cmd.ExecuteNonQuery(); + } + } + finally + { + // + // Notify partner that I have finished my work + // + myLock.Release(); + } + // + // Wait for partner to finish its work + // + if (!partnerLock.Wait(120000)) + { + throw new InvalidOperationException("Wait for partner has taken more than two minutes"); + } + + using (var cmd = new System.Data.SqlClient.SqlCommand("SELECT TOP 1 Data FROM DeadlockHelper ORDER BY Data", connection)) + { + // + // Requires shared lock on table, should be blocked by + // partner's exclusive lock + // + cmd.ExecuteNonQuery(); + } + } + } +} diff --git a/src/NHibernate.Test/NHSpecificTest/NH3023/DomainClass.cs b/src/NHibernate.Test/NHSpecificTest/NH3023/DomainClass.cs new file mode 100644 index 00000000000..85aca263d06 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH3023/DomainClass.cs @@ -0,0 +1,9 @@ +namespace NHibernate.Test.NHSpecificTest.NH3023 +{ + public class DomainClass + { + public virtual int Id { get; set; } + + public virtual byte[] ByteData { get; set; } + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/NH3023/Mappings.hbm.xml b/src/NHibernate.Test/NHSpecificTest/NH3023/Mappings.hbm.xml new file mode 100644 index 00000000000..77137520c45 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH3023/Mappings.hbm.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/NH3023/db-seed.sql b/src/NHibernate.Test/NHSpecificTest/NH3023/db-seed.sql new file mode 100644 index 00000000000..1501bf8e531 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH3023/db-seed.sql @@ -0,0 +1,20 @@ +create table DeadlockHelper ( + Id int identity(1, 1), + CLId int, + Data uniqueidentifier +) +create clustered index IX_CLId on DeadlockHelper(CLId) +go + +set nocount on + +insert into DeadlockHelper values (2, newid()) + +-- Boosting speed by inserting more and more at each loop (from 25s with old code down to 9s on my setup) +while ident_current('DeadlockHelper') <= 5000 + insert into DeadlockHelper select top 1000 2, newid() from DeadlockHelper + +delete from DeadlockHelper where Id > 5001 + +update DeadlockHelper set CLId = 1 where Id <= 10 +update DeadlockHelper set CLId = 3 where Id > 4900 \ No newline at end of file diff --git a/src/NHibernate.Test/NHSpecificTest/NH3023/db-teardown.sql b/src/NHibernate.Test/NHSpecificTest/NH3023/db-teardown.sql new file mode 100644 index 00000000000..a7af7359988 --- /dev/null +++ b/src/NHibernate.Test/NHSpecificTest/NH3023/db-teardown.sql @@ -0,0 +1 @@ +drop table DeadlockHelper diff --git a/src/NHibernate.Test/NHibernate.Test.csproj b/src/NHibernate.Test/NHibernate.Test.csproj index 268c7fd25fd..6ec37b423d6 100644 --- a/src/NHibernate.Test/NHibernate.Test.csproj +++ b/src/NHibernate.Test/NHibernate.Test.csproj @@ -24,6 +24,10 @@ + + + + Always diff --git a/src/NHibernate.Test/SessionBuilder/Fixture.cs b/src/NHibernate.Test/SessionBuilder/Fixture.cs index e457bb52397..dfd17982e68 100644 --- a/src/NHibernate.Test/SessionBuilder/Fixture.cs +++ b/src/NHibernate.Test/SessionBuilder/Fixture.cs @@ -12,7 +12,7 @@ public class Fixture : TestCase { protected override string MappingsAssembly => "NHibernate.Test"; - protected override IList Mappings => new [] { "SessionBuilder.Mappings.hbm.xml" }; + protected override IList Mappings => new[] { "SessionBuilder.Mappings.hbm.xml" }; protected override void Configure(Configuration configuration) { @@ -36,12 +36,51 @@ private void CanSetAutoClose(T sb) where T : ISessionBuilder var options = DebugSessionFactory.GetCreationOptions(sb); CanSet(sb, sb.AutoClose, () => options.ShouldAutoClose, sb is ISharedSessionBuilder ssb ? ssb.AutoClose : default(Func), - // initial values + // initial value false, // values true, false); } + [Test] + public void CanSetAutoJoinTransaction() + { + var sb = Sfi.WithOptions(); + CanSetAutoJoinTransaction(sb); + using (var s = sb.OpenSession()) + { + CanSetAutoJoinTransaction(s.SessionWithOptions()); + } + } + + private void CanSetAutoJoinTransaction(T sb) where T : ISessionBuilder + { + var options = DebugSessionFactory.GetCreationOptions(sb); + CanSet(sb, sb.AutoJoinTransaction, () => options.ShouldAutoJoinTransaction, + sb is ISharedSessionBuilder ssb ? ssb.AutoJoinTransaction : default(Func), + // initial value + true, + // values + false, true); + } + + [Test] + public void CanSetAutoJoinTransactionOnStateless() + { + var sb = Sfi.WithStatelessOptions(); + + var sbType = sb.GetType().Name; + var options = DebugSessionFactory.GetCreationOptions(sb); + Assert.That(options.ShouldAutoJoinTransaction, Is.True, $"{sbType}: Initial value"); + var fsb = sb.AutoJoinTransaction(false); + Assert.That(options.ShouldAutoJoinTransaction, Is.False, $"{sbType}: After call with false"); + Assert.That(fsb, Is.SameAs(sb), $"{sbType}: Unexpected fluent return after call with false"); + + fsb = sb.AutoJoinTransaction(true); + Assert.That(options.ShouldAutoJoinTransaction, Is.True, $"{sbType}: After call with true"); + Assert.That(fsb, Is.SameAs(sb), $"{sbType}: Unexpected fluent return after call with true"); + } + [Test] public void CanSetConnection() { @@ -137,7 +176,7 @@ private void CanSetConnectionReleaseMode(T sb) where T : ISessionBuilder var options = DebugSessionFactory.GetCreationOptions(sb); CanSet(sb, sb.ConnectionReleaseMode, () => options.SessionConnectionReleaseMode, sb is ISharedSessionBuilder ssb ? ssb.ConnectionReleaseMode : default(Func), - // initial values + // initial value Sfi.Settings.ConnectionReleaseMode, // values ConnectionReleaseMode.OnClose, ConnectionReleaseMode.AfterStatement, ConnectionReleaseMode.AfterTransaction); @@ -159,7 +198,7 @@ private void CanSetFlushMode(T sb) where T : ISessionBuilder var options = DebugSessionFactory.GetCreationOptions(sb); CanSet(sb, sb.FlushMode, () => options.InitialSessionFlushMode, sb is ISharedSessionBuilder ssb ? ssb.FlushMode : default(Func), - // initial values + // initial value Sfi.Settings.DefaultFlushMode, // values FlushMode.Always, FlushMode.Auto, FlushMode.Commit, FlushMode.Manual); diff --git a/src/NHibernate.Test/SystemTransactions/DistributedSystemTransactionFixture.cs b/src/NHibernate.Test/SystemTransactions/DistributedSystemTransactionFixture.cs new file mode 100644 index 00000000000..b9b8e089f0e --- /dev/null +++ b/src/NHibernate.Test/SystemTransactions/DistributedSystemTransactionFixture.cs @@ -0,0 +1,823 @@ +using System; +using System.Linq; +using System.Threading; +using System.Transactions; +using log4net; +using log4net.Repository.Hierarchy; +using NHibernate.Cfg; +using NHibernate.Engine; +using NHibernate.Linq; +using NHibernate.Test.TransactionTest; +using NUnit.Framework; + +namespace NHibernate.Test.SystemTransactions +{ + [TestFixture] + public class DistributedSystemTransactionFixture : SystemTransactionFixtureBase + { + private static readonly ILog _log = LogManager.GetLogger(typeof(DistributedSystemTransactionFixture)); + protected override bool UseConnectionOnSystemTransactionPrepare => true; + protected override bool AutoJoinTransaction => true; + + protected override bool AppliesTo(Dialect.Dialect dialect) + => dialect.SupportsDistributedTransactions && base.AppliesTo(dialect); + + protected override void OnTearDown() + { + DodgeTransactionCompletionDelayIfRequired(); + base.OnTearDown(); + } + + [Test] + public void SupportsEnlistingInDistributed() + { + using (new TransactionScope()) + { + ForceEscalationToDistributedTx.Escalate(); + + Assert.AreNotEqual( + Guid.Empty, + System.Transactions.Transaction.Current.TransactionInformation.DistributedIdentifier, + "Transaction lacks a distributed identifier"); + + using (var s = OpenSession()) + { + s.Save(new Person()); + // Ensure the connection is acquired (thus enlisted) + Assert.DoesNotThrow(s.Flush, "Failure enlisting a connection in a distributed transaction."); + } + } + } + + [Test] + public void SupportsPromotingToDistributed() + { + using (new TransactionScope()) + { + using (var s = OpenSession()) + { + s.Save(new Person()); + // Ensure the connection is acquired (thus enlisted) + s.Flush(); + } + Assert.DoesNotThrow(() => ForceEscalationToDistributedTx.Escalate(), + "Failure promoting the transaction to distributed while already having enlisted a connection."); + Assert.AreNotEqual( + Guid.Empty, + System.Transactions.Transaction.Current.TransactionInformation.DistributedIdentifier, + "Transaction lacks a distributed identifier"); + } + } + + [Test] + public void WillNotCrashOnPrepareFailure() + { + IgnoreIfUnsupported(false); + var tx = new TransactionScope(); + var disposeCalled = false; + try + { + using (var s = OpenSession()) + { + s.Save(new Person { NotNullData = null }); // Cause a SQL not null constraint violation. + } + + ForceEscalationToDistributedTx.Escalate(); + + tx.Complete(); + disposeCalled = true; + Assert.Throws(tx.Dispose, "Scope disposal has not rollback and throw."); + } + finally + { + if (!disposeCalled) + { + try + { + tx.Dispose(); + } + catch + { + // Ignore, if disposed has not been called, another exception has occurred in the try and + // we should avoid overriding it by the disposal failure. + } + } + } + } + + [Theory] + public void CanRollbackTransaction(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + var tx = new TransactionScope(); + var disposeCalled = false; + try + { + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(true); //will rollback tx + s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + disposeCalled = true; + Assert.Throws(tx.Dispose, "Scope disposal has not rollback and throw."); + } + finally + { + if (!disposeCalled) + { + try + { + tx.Dispose(); + } + catch + { + // Ignore, if disposed has not been called, another exception has occurred in the try and + // we should avoid overriding it by the disposal failure. + } + } + } + + AssertNoPersons(); + } + + [Theory] + public void CanRollbackTransactionFromScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope()) + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(); + s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + // No Complete call for triggering rollback. + } + + AssertNoPersons(); + } + + [Theory] + [Description("Another action inside the transaction do the rollBack outside nh-session-scope.")] + public void RollbackOutsideNh(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + try + { + using (var txscope = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new Person(); + s.Save(person); + + if (explicitFlush) + s.Flush(); + } + ForceEscalationToDistributedTx.Escalate(true); //will rollback tx + + txscope.Complete(); + } + + Assert.Fail("Scope disposal has not rollback and throw."); + } + catch (TransactionAbortedException) + { + _log.Debug("Transaction aborted."); + } + + AssertNoPersons(); + } + + [Theory] + [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] + public void TransactionInsertWithRollBackFromScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new Person(); + s.Save(person); + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + s.Flush(); + } + // No Complete call for triggering rollback. + } + AssertNoPersons(); + } + + [Theory] + [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] + public void TransactionInsertWithRollBackTask(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + try + { + using (var txscope = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new Person(); + s.Save(person); + ForceEscalationToDistributedTx.Escalate(true); //will rollback tx + + if (explicitFlush) + s.Flush(); + } + txscope.Complete(); + } + + Assert.Fail("Scope disposal has not rollback and throw."); + } + catch (TransactionAbortedException) + { + _log.Debug("Transaction aborted."); + } + + AssertNoPersons(); + } + + [Theory] + [Description(@"Two session in two txscope + (without an explicit NH transaction) + and with a rollback in the second dtc and a rollback outside nh-session-scope.")] + public void TransactionInsertLoadWithRollBackFromScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object savedId; + var createdAt = DateTime.Today; + using (var txscope = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new Person { CreatedAt = createdAt }; + savedId = s.Save(person); + + if (explicitFlush) + s.Flush(); + } + txscope.Complete(); + } + + using (new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = s.Get(savedId); + person.CreatedAt = createdAt.AddMonths(-1); + + if (explicitFlush) + s.Flush(); + } + ForceEscalationToDistributedTx.Escalate(); + + // No Complete call for triggering rollback. + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(createdAt, s.Get(savedId).CreatedAt, "Entity update was not rollback-ed."); + } + } + + [Theory] + [Description(@"Two session in two txscope + (without an explicit NH transaction) + and with a rollback in the second dtc and a ForceRollback outside nh-session-scope.")] + public void TransactionInsertLoadWithRollBackTask(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object savedId; + var createdAt = DateTime.Today; + using (var txscope = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new Person { CreatedAt = createdAt }; + savedId = s.Save(person); + + if (explicitFlush) + s.Flush(); + } + txscope.Complete(); + } + + try + { + using (var txscope = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = s.Get(savedId); + person.CreatedAt = createdAt.AddMonths(-1); + + if (explicitFlush) + s.Flush(); + } + ForceEscalationToDistributedTx.Escalate(true); + + _log.Debug("completing the tx scope"); + txscope.Complete(); + } + _log.Debug("Transaction fail."); + Assert.Fail("Expected tx abort"); + } + catch (TransactionAbortedException) + { + _log.Debug("Transaction aborted."); + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(createdAt, s.Get(savedId).CreatedAt, "Entity update was not rollback-ed."); + } + } + + private int _totalCall; + + [Test, Explicit("Test added for NH-1709 (trying to recreate the issue... without luck). If one thread break the test, you can see the result in the console.")] + public void MultiThreadedTransaction() + { + // Test added for NH-1709 (trying to recreate the issue... without luck) + // If one thread break the test, you can see the result in the console. + ((Logger)_log.Logger).Level = log4net.Core.Level.Debug; + var actions = new MultiThreadRunner.ExecuteAction[] + { + delegate + { + CanRollbackTransaction(false); + _totalCall++; + }, + delegate + { + RollbackOutsideNh(false); + _totalCall++; + }, + delegate + { + TransactionInsertWithRollBackTask(false); + _totalCall++; + }, + delegate + { + TransactionInsertLoadWithRollBackTask(false); + _totalCall++; + }, + }; + var mtr = new MultiThreadRunner(20, actions) + { + EndTimeout = 5000, + TimeoutBetweenThreadStart = 5 + }; + mtr.Run(null); + _log.DebugFormat("{0} calls", _totalCall); + } + + [Theory] + public void CanDeleteItemInDtc(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object id; + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + id = s.Save(new Person()); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + } + + DodgeTransactionCompletionDelayIfRequired(); + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(1, s.Query().Count(), "Entity not found in database."); + } + + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(); + + s.Delete(s.Get(id)); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + } + + DodgeTransactionCompletionDelayIfRequired(); + + AssertNoPersons(); + } + + [Test] + [Description("Open/Close a session inside a TransactionScope fails.")] + public void NH1744() + { + using (new TransactionScope()) + { + using (var s = OpenSession()) + { + s.Flush(); + } + + using (var s = OpenSession()) + { + s.Flush(); + } + + //and I always leave the transaction disposed without calling tx.Complete(), I let the database server to rollback all actions in this test. + } + } + + [Theory] + public void CanUseSessionWithManyScopes(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + // Note that this fails with ConnectionReleaseMode.OnClose and SqlServer: + // System.Data.SqlClient.SqlException : Microsoft Distributed Transaction Coordinator (MS DTC) has stopped this transaction. + // Not much an issue since it is advised to not use ConnectionReleaseMode.OnClose. + using (var s = OpenSession()) + //using (var s = Sfi.WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope()) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + // Acquire the connection + var count = s.Query().Count(); + Assert.That(count, Is.EqualTo(0), "Unexpected initial entity count."); + tx.Complete(); + } + // No dodge here please! Allow to check chaining usages do not fail. + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + + DodgeTransactionCompletionDelayIfRequired(); + + using (var tx = new TransactionScope()) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = s.Query().Count(); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after committed insert."); + tx.Complete(); + } + using (new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + s.Flush(); + + // No complete for rollback-ing. + } + + DodgeTransactionCompletionDelayIfRequired(); + + // Do not reuse the session after a rollback, its state does not allow it. + // http://nhibernate.info/doc/nhibernate-reference/manipulatingdata.html#manipulatingdata-endingsession-commit + } + + using (var s = OpenSession()) + { + using (var tx = new TransactionScope()) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = s.Query().Count(); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after rollback-ed insert."); + tx.Complete(); + } + } + } + + [Theory] + public void CanUseSessionOutsideOfScopeAfterScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + // Note that this fails with ConnectionReleaseMode.OnClose and Npgsql (< 3.2.5?): + // NpgsqlOperationInProgressException: The connection is already in state 'Executing' + // Not much an issue since it is advised to not use ConnectionReleaseMode.OnClose. + using (var s = OpenSession()) + //using (var s = WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + var count = 0; + Assert.DoesNotThrow(() => count = s.Query().Count(), "Failed using the session after scope."); + if (count != 1) + // We are not testing that here, so just issue a warning. Do not use DodgeTransactionCompletionDelayIfRequired + // before previous assert. We want to ascertain the session is usable in any cases. + Assert.Warn("Unexpected entity count: {0} instead of {1}. The transaction seems to have a delayed commit.", count, 1); + } + } + + [Theory] + [Description("Do not fail, but warn in case a delayed after scope disposal commit is made.")] + public void DelayedTransactionCompletion(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + for (var i = 1; i <= 10; i++) + { + // Isolation level must be read committed on the control session: reading twice while expecting some data insert + // in between due to a late commit. Repeatable read would block and read uncommitted would see the uncommitted data. + using (var controlSession = OpenSession()) + using (controlSession.BeginTransaction(System.Data.IsolationLevel.ReadCommitted)) + { + // We want to have the control session as ready to query as possible, thus beginning its + // transaction early for acquiring the connection, even if we will not use it before + // below scope completion. + + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + s.Save(new Person()); + + ForceEscalationToDistributedTx.Escalate(); + + if (explicitFlush) + s.Flush(); + } + tx.Complete(); + } + + var count = controlSession.Query().Count(); + if (count != i) + { + // See https://github.com/npgsql/npgsql/issues/1571#issuecomment-308651461 discussion with a Microsoft + // employee: MSDTC consider a transaction to be committed once it has collected all participant votes + // for committing from prepare phase. It then immediately notifies all participants of the outcome. + // This causes TransactionScope.Dispose to leave while the second phase of participants may still + // be executing. This means the transaction from the db view point can still be pending and not yet + // committed. This is by design of MSDTC and we have to cope with that. Some data provider may have + // a global locking mechanism causing any subsequent use to wait for the end of the commit phase, + // but this is not the usual case. Some other, as Npgsql < v3.2.5, may crash due to this, because + // they re-use the connection for the second phase. + Thread.Sleep(100); + var countSecondTry = controlSession.Query().Count(); + Assert.Warn($"Unexpected entity count: {count} instead of {i}. " + + "This may mean current data provider has a delayed commit, occurring after scope disposal. " + + $"After waiting, count is now {countSecondTry}. "); + break; + } + } + } + } + + // Taken and adjusted from NH1632 When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_level_cache + [Test] + public void WhenCommittingItemsAfterSessionDisposalWillAddThemTo2ndLevelCache() + { + int id; + const string notNullData = "test"; + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new CacheablePerson { NotNullData = notNullData }; + s.Save(person); + id = person.Id; + + ForceEscalationToDistributedTx.Escalate(); + + s.Flush(); + } + tx.Complete(); + } + + DodgeTransactionCompletionDelayIfRequired(); + + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(); + + var person = s.Load(id); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + tx.Complete(); + } + + // Closing the connection to ensure we can't actually use it. + var connection = Sfi.ConnectionProvider.GetConnection(); + Sfi.ConnectionProvider.CloseConnection(connection); + + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. + using (var s = WithOptions().Connection(connection).OpenSession()) + { + CacheablePerson person = null; + Assert.DoesNotThrow(() => person = s.Load(id), "Failed loading entity from second level cache."); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + } + + [Test] + public void DoNotDeadlockOnAfterTransactionWait() + { + var interceptor = new AfterTransactionWaitingInterceptor(); + using (var s = Sfi.WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope()) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + s.Flush(); + tx.Complete(); + } + Assert.That(interceptor.Exception, Is.Null); + } + + [Test] + public void EnforceConnectionUsageRulesOnTransactionCompletion() + { + var interceptor = new TransactionCompleteUsingConnectionInterceptor(); + // Do not invert session and scope, it would cause an expected failure when + // UseConnectionOnSystemTransactionEvents is false, due to the session being closed. + using (var s = Sfi.WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope()) + { + ForceEscalationToDistributedTx.Escalate(); + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + s.Flush(); + tx.Complete(); + } + + if (UseConnectionOnSystemTransactionPrepare) + { + Assert.That(interceptor.BeforeException, Is.Null); + } + else + { + Assert.That(interceptor.BeforeException, Is.TypeOf()); + } + // Currently always forbidden, whatever UseConnectionOnSystemTransactionEvents. + Assert.That(interceptor.AfterException, Is.TypeOf()); + } + + [Test] + public void AdditionalJoinDoesNotThrow() + { + using (new TransactionScope()) + using (var s = OpenSession()) + { + ForceEscalationToDistributedTx.Escalate(); + Assert.DoesNotThrow(() => s.JoinTransaction()); + } + } + + private void DodgeTransactionCompletionDelayIfRequired() + { + if (Sfi.ConnectionProvider.Driver.HasDelayedDistributedTransactionCompletion) + Thread.Sleep(500); + } + + public class ForceEscalationToDistributedTx : IEnlistmentNotification + { + private readonly bool _shouldRollBack; + private readonly int _thread; + + public static void Escalate(bool shouldRollBack = false) + { + var force = new ForceEscalationToDistributedTx(shouldRollBack); + System.Transactions.Transaction.Current.EnlistDurable(Guid.NewGuid(), force, EnlistmentOptions.None); + } + + private ForceEscalationToDistributedTx(bool shouldRollBack) + { + _shouldRollBack = shouldRollBack; + _thread = Thread.CurrentThread.ManagedThreadId; + } + + public void Prepare(PreparingEnlistment preparingEnlistment) + { + if (_thread == Thread.CurrentThread.ManagedThreadId) + { + _log.Warn("Thread.CurrentThread.ManagedThreadId ({0}) is same as creation thread"); + } + + if (_shouldRollBack) + { + _log.Debug(">>>>Force Rollback<<<<<"); + preparingEnlistment.ForceRollback(); + } + else + { + preparingEnlistment.Prepared(); + } + } + + public void Commit(Enlistment enlistment) + { + enlistment.Done(); + } + + public void Rollback(Enlistment enlistment) + { + enlistment.Done(); + } + + public void InDoubt(Enlistment enlistment) + { + enlistment.Done(); + } + } + } + + [TestFixture] + public class DistributedSystemTransactionWithoutConnectionFromPrepareFixture : DistributedSystemTransactionFixture + { + protected override bool UseConnectionOnSystemTransactionPrepare => false; + } + + [TestFixture] + public class DistributedSystemTransactionWithoutAutoJoinTransaction : DistributedSystemTransactionFixture + { + protected override bool AutoJoinTransaction => false; + + protected override void Configure(Configuration configuration) + { + base.Configure(configuration); + DisableConnectionAutoEnlist(configuration); + } + + protected override bool AppliesTo(ISessionFactoryImplementor factory) + => base.AppliesTo(factory) && factory.ConnectionProvider.Driver.SupportsEnlistmentWhenAutoEnlistmentIsDisabled; + + [Test] + public void SessionIsNotEnlisted() + { + using (new TransactionScope()) + { + ForceEscalationToDistributedTx.Escalate(); + // Dodge the OpenSession override which call JoinTransaction by calling WithOptions(). + using (var s = WithOptions().OpenSession()) + { + Assert.That(s.GetSessionImplementation().TransactionContext, Is.Null); + } + } + } + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/SystemTransactions/ResourceManagerFixture.cs b/src/NHibernate.Test/SystemTransactions/ResourceManagerFixture.cs new file mode 100644 index 00000000000..4852e0315aa --- /dev/null +++ b/src/NHibernate.Test/SystemTransactions/ResourceManagerFixture.cs @@ -0,0 +1,925 @@ +using System; +using System.IO; +using System.Threading; +using System.Transactions; +using log4net; +using log4net.Layout; +using log4net.Repository.Hierarchy; +using NUnit.Framework; + +using SysTran = System.Transactions.Transaction; + +namespace NHibernate.Test.SystemTransactions +{ + /// + /// Holds tests for checking MSDTC resource managers behavior. They are not actual NHibernate tests, + /// they are here to help understand how NHibernate should implement its own resource manager. + /// + [Explicit("Does not test NHibernate but MSDTC")] + public class ResourceManagerFixture + { + #region Distributed + + // All these tests demonstrates the asynchronism of MSDTC. + // - Prepare phases of resources run concurrently to each other, in no predictable order. But they may + // not be concurrent with code following the scope disposal. + // - Second phases and transaction complete events are fully concurrent. Second phase may run in + // parallel with complete event of the same resource. Second phases of different resources run in + // parallel too, ... All that in concurrence with code following the scope disposal. + + #region Commit + + // enlistInPrepare enable this option for the "session" resource. In such case, the "session" + // resource is prepared before others in all commit tests. So we could count on this for not + // opening a new connection and instead use the current one, but some reports (NH-2238, NH-3968) + // indicates this may not always be true. + + [Test] + public void DistributedCommit([Values(false, true)] bool enlistInPrepare) + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a simple connection: durable resource supporting single phase. + // (Note that SQL Server 2005 and above use IPromotableSinglePhaseNotification + // for delegating the resource management to the SQL server.) + EnlistResource.EnlistDurable(false, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate another resource, not even supporting single phase + EnlistResource.EnlistDurable(); + _log.InfoFormat( + "Fake other resource, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + if (enlistInPrepare) + EnlistResource.EnlistWithPrepareEnlistmentVolatile(); + else + EnlistResource.EnlistVolatile(); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + scope.Complete(); + _log.Info("Scope completed"); + } + _log.Info("Scope disposed"); + } + + [Test] + public void DistributedNpgsqlCommit([Values(false, true)] bool enlistInPrepare) + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a Npgsql connection: as of Npgsql 3.2.4, volatile resource with single phase support + EnlistResource.EnlistVolatile(false, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate another resource, not even supporting single phase (required for going distributed with "Npgsql") + EnlistResource.EnlistDurable(); + _log.InfoFormat( + "Fake other resource, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + if (enlistInPrepare) + EnlistResource.EnlistWithPrepareEnlistmentVolatile(); + else + EnlistResource.EnlistVolatile(); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + scope.Complete(); + _log.Info("Scope completed"); + } + _log.Info("Scope disposed"); + } + + #endregion + + #region Failure + + [Test] + [Explicit("Failing.")] + public void DistributedTransactionStatusMustBeInactiveAfterRollbackedScope() + { + SysTran transaction = null; + try + { + using (CreateDistributedTransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // The trouble occurs only with cloned transaction. The original one is disposed before and so + // considered inactive by FailsafeGetTransactionStatus test. + transaction = SysTran.Current.Clone(); + _log.Info("Scope not completed"); + } + _log.Info("Scope disposed"); + Assert.That(FailsafeGetTransactionStatus(transaction), Is.Not.EqualTo(TransactionStatus.Active)); + } + finally + { + transaction?.Dispose(); + } + } + + [Test] + [Explicit("Failing")] + public void DistributedTransactionFromCompletionEventShouldBeTheOneToWhichTheEventIsAttached() + { + SysTran clone = null; + SysTran eventTransaction = null; + try + { + using (CreateDistributedTransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + clone = SysTran.Current.Clone(); + clone.TransactionCompleted += Clone_TransactionCompleted; + _log.Info("Scope not completed"); + } + _log.Info("Scope disposed"); + while (eventTransaction == null) + Thread.Sleep(10); + _log.Info("Event transaction received"); + Assert.That(eventTransaction, Is.SameAs(clone)); + } + finally + { + clone?.Dispose(); + } + + void Clone_TransactionCompleted(object sender, TransactionEventArgs e) + { + eventTransaction = e.Transaction; + } + } + + // Failing in phase 2 seems almost a no-op. But it indeed has averse effects: the transaction will be marked + // as "Cannot Notify" and will not be cleared from MSDTC logs. + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms681727.aspx + // A failure in phase 2 is to be avoided. + [Test] + [Explicit("Causes a transaction to remain in MSDTC logs. Clean-it with comexp.msc.")] + public void DistributedFailureInSecondPhase() + { + using (var scope = new TransactionScope(TransactionScopeOption.Required, TimeSpan.FromSeconds(5))) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a failing connection + EnlistResource.EnlistSecondPhaseFailingDurable(); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate another resource, not even supporting single phase + EnlistResource.EnlistDurable(); + _log.InfoFormat( + "Fake other resource, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + enlist in prepare option + EnlistResource.EnlistWithPrepareEnlistmentVolatile(); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + scope.Complete(); + _log.Info("Scope completed"); + } + _log.Info("Scope disposed"); + } + + // Fails because throwing exception from prepare, without notifying the enlistment. + // MSDTC then just wait the timeout. Demonstrates enlistment must always be notified, + // failure is not an option. + [Test] + public void DistributedInDoubtFailure() + { + try + { + using (var scope = new TransactionScope(TransactionScopeOption.Required, TimeSpan.FromSeconds(5))) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a simple connection: durable resource supporting single phase. + // (Note that SQL Server 2005 and above use IPromotableSinglePhaseNotification + // for delegating the resource management to the SQL server.) + EnlistResource.EnlistInDoubtDurable(); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate another resource, not even supporting single phase + EnlistResource.EnlistDurable(); + _log.InfoFormat( + "Fake other resource, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + enlist in prepare option + EnlistResource.EnlistWithPrepareEnlistmentVolatile(); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + scope.Complete(); + _log.Info("Scope completed"); + } + } + catch (TransactionAbortedException) + { + // expected + } + _log.Info("Scope disposed"); + } + + #endregion + + #region Rollback + + // Demonstrates that in rollback cases, the prepare phases may not be called at all. If we have to lock + // the session from being used till second phase end, we cannot rely on prepare phase for this. + + [Test] + public void DistributedRollback([Values(false, true)] bool fromConnection, [Values(false, true)] bool fromOther, [Values(false, true)] bool fromSession) + { + var shouldFail = fromConnection || fromSession || fromOther; + try + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a simple connection: durable resource supporting single phase. + // (Note that SQL Server 2005 and above use IPromotableSinglePhaseNotification + // for delegating the resource management to the SQL server.) + EnlistResource.EnlistDurable(fromConnection, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate another resource, not even supporting single phase + EnlistResource.EnlistDurable(fromOther); + _log.InfoFormat( + "Fake other resource, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + enlist in prepare option + EnlistResource.EnlistWithPrepareEnlistmentVolatile(fromSession); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + if (shouldFail) + { + scope.Complete(); + _log.Info("Scope completed"); + } + else + _log.Info("Scope not completed for triggering rollback"); + } + } + catch (TransactionAbortedException) + { + if (!shouldFail) + throw; + } + _log.Info("Scope disposed"); + } + + [Test] + public void DistributedNpgsqlRollback([Values(false, true)] bool fromConnection, [Values(false, true)] bool fromOther, [Values(false, true)] bool fromSession) + { + var shouldFail = fromConnection || fromSession || fromOther; + try + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a Npgsql connection: as of Npgsql 3.2.4, volatile resource with single phase support + EnlistResource.EnlistVolatile(fromConnection, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate another resource, not even supporting single phase (required for going distributed with "Npgsql") + EnlistResource.EnlistDurable(fromOther); + _log.InfoFormat( + "Fake other resource, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + enlist in prepare option + EnlistResource.EnlistWithPrepareEnlistmentVolatile(fromSession); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + if (shouldFail) + { + scope.Complete(); + _log.Info("Scope completed"); + } + else + _log.Info("Scope not completed for triggering rollback"); + } + } + catch (TransactionAbortedException) + { + if (!shouldFail) + throw; + } + _log.Info("Scope disposed"); + } + + [Test] + public void DistributedTransactionStatusFromCompletionEventShouldNotBeActiveOnRollback() + { + SysTran clone = null; + SysTran eventTransaction = null; + TransactionStatus? cloneStatusAtCompletion = null; + try + { + using (CreateDistributedTransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + clone = SysTran.Current.Clone(); + clone.TransactionCompleted += Clone_TransactionCompleted; + _log.Info("Scope not completed"); + } + _log.Info("Scope disposed"); + while (eventTransaction == null) + Thread.Sleep(10); + _log.Info("Event transaction received"); + Assert.That(cloneStatusAtCompletion, Is.Not.EqualTo(TransactionStatus.Active)); + } + finally + { + clone?.Dispose(); + } + + void Clone_TransactionCompleted(object sender, TransactionEventArgs e) + { + cloneStatusAtCompletion = FailsafeGetTransactionStatus(clone); + eventTransaction = e.Transaction; + } + } + + #endregion + + #endregion + + #region Non distributed + + // No asynchronism to be seen in those cases. + + #region Commit + + // If this case was the sole case for non-distributed scopes, we could optimize the flush from + // prepare by re-using the main connection, since this case showcase that durable resource + // supporting single phase are single phase executed after volatile resource prepare phase. + // Unfortunately this is not the sole case. Unless checking code of each provider, we should + // use another connection for the flush from prepare even in non-distributed cases. + [Test] + public void NonDistributedCommit([Values(false, true)] bool enlistInPrepare) + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a simple connection: durable resource supporting single phase. + // (Note that SQL Server 2005 and above use IPromotableSinglePhaseNotification + // for delegating the resource management to the SQL server.) + EnlistResource.EnlistDurable(false, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + if (enlistInPrepare) + EnlistResource.EnlistWithPrepareEnlistmentVolatile(); + else + EnlistResource.EnlistVolatile(); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + scope.Complete(); + _log.Info("Scope completed"); + } + _log.Info("Scope disposed"); + } + + // This one led to reporting https://github.com/npgsql/npgsql/issues/1625, when enlistInPrepare + // is false. In this case, the single phase optimization of the "connection" is not called, + // causing it to go through 2PC. + [Test] + public void NonDistributedNpgsqlCommit([Values(false, true)] bool enlistInPrepare) + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a Npgsql connection: as of Npgsql 3.2.4, volatile resource with single phase support + EnlistResource.EnlistVolatile(false, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + if (enlistInPrepare) + EnlistResource.EnlistWithPrepareEnlistmentVolatile(); + else + EnlistResource.EnlistVolatile(); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + scope.Complete(); + _log.Info("Scope completed"); + } + _log.Info("Scope disposed"); + } + + #endregion + + #region In-doubt + + // This case indicates that the complete event is triggered in in-doubt case. Firing transaction + // completion from in-doubt and complete case seems to have no grounding. + // Since failures in notifications can fill MSDTC logs till hanging it, better move all eligible + // processing to transaction completed. So we should do transaction completion only in + // complete event, no more in in-doubt. + [Test] + public void NonDistributedInDoubt() + { + try + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a simple connection: durable resource supporting single phase. + // (Note that SQL Server 2005 and above use IPromotableSinglePhaseNotification + // for delegating the resource management to the SQL server.) + EnlistResource.EnlistInDoubtDurable(); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + enlist in prepare option + EnlistResource.EnlistWithPrepareEnlistmentVolatile(); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + scope.Complete(); + _log.Info("Scope completed"); + } + } + catch (TransactionInDoubtException) + { + // expected + } + _log.Info("Scope disposed"); + } + + #endregion + + #region Rollback + + [Test] + public void TransactionStatusMustBeInactiveAfterRollbackedScope() + { + SysTran transaction = null; + try + { + using (new TransactionScope()) + { + transaction = SysTran.Current.Clone(); + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + } + _log.Info("Scope disposed"); + Assert.That(FailsafeGetTransactionStatus(transaction), Is.Not.EqualTo(TransactionStatus.Active)); + } + finally + { + transaction?.Dispose(); + } + } + + [Test] + public void NonDistributedRollback([Values(false, true)] bool fromConnection, [Values(false, true)] bool fromSession) + { + var shouldFail = fromConnection || fromSession; + try + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a simple connection: durable resource supporting single phase. + // (Note that SQL Server 2005 and above use IPromotableSinglePhaseNotification + // for delegating the resource management to the SQL server.) + EnlistResource.EnlistDurable(fromConnection, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + enlist in prepare option + EnlistResource.EnlistWithPrepareEnlistmentVolatile(fromSession); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + if (shouldFail) + { + scope.Complete(); + _log.Info("Scope completed"); + } + else + _log.Info("Scope not completed for triggering rollback"); + + } + } + catch (TransactionAbortedException) + { + if (!shouldFail) + throw; + } + _log.Info("Scope disposed"); + } + + [Test] + public void NonDistributedNpgsqlRollback([Values(false, true)] bool fromConnection, [Values(false, true)] bool fromSession) + { + var shouldFail = fromConnection || fromSession; + try + { + using (var scope = new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate a Npgsql connection: as of Npgsql 3.2.4, volatile resource with single phase support + EnlistResource.EnlistVolatile(fromConnection, true); + _log.InfoFormat( + "Fake connection opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + // Simulate NHibernate : volatile no single phase support + enlist in prepare option + EnlistResource.EnlistWithPrepareEnlistmentVolatile(fromSession); + _log.InfoFormat( + "Fake session opened, scope id {0} and distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + if (shouldFail) + { + scope.Complete(); + _log.Info("Scope completed"); + } + else + _log.Info("Scope not completed for triggering rollback"); + } + } + catch (TransactionAbortedException) + { + if (!shouldFail) + throw; + } + _log.Info("Scope disposed"); + } + + #endregion + + #region Failure + + [Test] + [Explicit("Failing")] + public void TransactionFromCompletionEventShouldBeTheOneToWhichTheEventIsAttached() + { + SysTran clone = null; + SysTran eventTransaction = null; + try + { + using (new TransactionScope()) + { + _log.InfoFormat( + "Scope opened, id {0}, distributed id {1}", + SysTran.Current.TransactionInformation.LocalIdentifier, + SysTran.Current.TransactionInformation.DistributedIdentifier); + clone = SysTran.Current.Clone(); + clone.TransactionCompleted += Clone_TransactionCompleted; + _log.Info("Scope not completed"); + } + _log.Info("Scope disposed"); + while (eventTransaction == null) + Thread.Sleep(10); + _log.Info("Event transaction received"); + Assert.That(eventTransaction, Is.SameAs(clone)); + } + finally + { + clone?.Dispose(); + } + + void Clone_TransactionCompleted(object sender, TransactionEventArgs e) + { + eventTransaction = e.Transaction; + } + } + + #endregion + + #endregion + + #region Tests setup/teardown/utils + + private static readonly ILog _log = LogManager.GetLogger(typeof(ResourceManagerFixture)); + private LogSpy _spy; + + [OneTimeSetUp] + public void TestFixtureSetUp() + { + ((Logger)_log.Logger).Level = log4net.Core.Level.Info; + _spy = new LogSpy(_log); + _spy.Appender.Layout = new PatternLayout("%d{ABSOLUTE} [%t] - %m%n"); + } + + [OneTimeTearDown] + public void TestFixtureTearDown() + { + _spy.Dispose(); + } + + [SetUp] + public void SetUp() + { + EnlistResource.Counter = 0; + } + + [TearDown] + public void TearDown() + { + // Account for MSDTC async second phase, for collecting all logs + Thread.Sleep(200); + + using (var wholeMessage = new StringWriter()) + { + foreach (var loggingEvent in _spy.Appender.PopAllEvents()) + { + _spy.Appender.Layout.Format(wholeMessage, loggingEvent); + } + // R# console ignores logs from other threads. + _log.Info( + @" + +All threads log: +" + wholeMessage); + } + _spy.Appender.Clear(); + } + + // Taken from NH-3023 test. + private static TransactionScope CreateDistributedTransactionScope() + { + var scope = new TransactionScope(); + // + // Forces promotion to distributed transaction + // + TransactionInterop.GetTransmitterPropagationToken(System.Transactions.Transaction.Current); + return scope; + } + + private static TransactionStatus? FailsafeGetTransactionStatus(SysTran transaction) + { + try + { + return transaction.TransactionInformation.Status; + } + catch (Exception ex) + { + // Only log exception message for avoid bloating the log for a minor case + _log.InfoFormat("Failed getting transaction status, {0}", ex.Message); + return null; + } + } + + public class EnlistResource : IEnlistmentNotification + { + // Causes concurrency to be more obvious. + public static int SleepTime { get; set; } = 2; + + public static int Counter { get; set; } + + protected bool ShouldRollBack { get; } + protected bool ShouldGoInDoubt { get; } + protected bool FailInSecondPhase { get; } + protected string Name { get; } + + public static void EnlistVolatile(bool shouldRollBack = false) + => EnlistVolatile(shouldRollBack, false); + + public static void EnlistVolatile(bool shouldRollBack, bool supportsSinglePhase) + => Enlist(false, supportsSinglePhase, shouldRollBack); + + public static void EnlistWithPrepareEnlistmentVolatile(bool shouldRollBack = false) + => Enlist(false, false, shouldRollBack, false, false, true); + + public static void EnlistDurable(bool shouldRollBack = false) + => EnlistDurable(shouldRollBack, false); + + public static void EnlistDurable(bool shouldRollBack, bool supportsSinglePhase) + => Enlist(true, supportsSinglePhase, shouldRollBack); + + public static void EnlistInDoubtDurable() + => Enlist(true, true, false, true); + + public static void EnlistSecondPhaseFailingDurable() + => Enlist(true, false, false, false, true); + + private static void Enlist(bool durable, bool supportsSinglePhase, bool shouldRollBack, bool inDoubt = false, + bool failInSecondPhase = false, bool enlistInPrepareOption = false) + { + Counter++; + + var name = $"{(durable ? "Durable" : "Volatile")} resource {Counter}"; + EnlistResource resource; + var options = enlistInPrepareOption ? EnlistmentOptions.EnlistDuringPrepareRequired : EnlistmentOptions.None; + if (supportsSinglePhase) + { + var spResource = new EnlistSinglePhaseResource(shouldRollBack, name, inDoubt, failInSecondPhase); + resource = spResource; + if (durable) + SysTran.Current.EnlistDurable(Guid.NewGuid(), spResource, options); + else + SysTran.Current.EnlistVolatile(spResource, options); + } + else + { + resource = new EnlistResource(shouldRollBack, name, inDoubt, failInSecondPhase); + // Not duplicate code with above, that is not the same overload which ends up called. + if (durable) + SysTran.Current.EnlistDurable(Guid.NewGuid(), resource, options); + else + SysTran.Current.EnlistVolatile(resource, options); + } + + SysTran.Current.TransactionCompleted += resource.Current_TransactionCompleted; + + _log.Info(name + ": enlisted"); + } + + protected EnlistResource(bool shouldRollBack, string name, bool inDoubt, bool failInSecondPhase) + { + ShouldRollBack = shouldRollBack; + ShouldGoInDoubt = inDoubt; + FailInSecondPhase = failInSecondPhase; + Name = name; + } + + public void Prepare(PreparingEnlistment preparingEnlistment) + { + _log.Info(Name + ": prepare phase start"); + Thread.Sleep(SleepTime); + if (ShouldRollBack) + { + _log.Info(Name + ": prepare phase, calling rollback-ed"); + preparingEnlistment.ForceRollback(); + } + else if (ShouldGoInDoubt) + { + throw new InvalidOperationException("In-doubt mode currently supported only by durable in single phase."); + } + else + { + _log.Info(Name + ": prepare phase, calling prepared"); + preparingEnlistment.Prepared(); + } + Thread.Sleep(SleepTime); + _log.Info(Name + ": prepare phase end"); + } + + public void Commit(Enlistment enlistment) + { + _log.Info(Name + ": commit phase start"); + Thread.Sleep(SleepTime); + if (FailInSecondPhase) + throw new InvalidOperationException("Asked to fail"); + _log.Info(Name + ": commit phase, calling done"); + enlistment.Done(); + Thread.Sleep(SleepTime); + _log.Info(Name + ": commit phase end"); + } + + public void Rollback(Enlistment enlistment) + { + _log.Info(Name + ": rollback phase start"); + Thread.Sleep(SleepTime); + if (FailInSecondPhase) + throw new InvalidOperationException("Asked to fail"); + _log.Info(Name + ": rollback phase, calling done"); + enlistment.Done(); + Thread.Sleep(SleepTime); + _log.Info(Name + ": rollback phase end"); + } + + public void InDoubt(Enlistment enlistment) + { + _log.Info(Name + ": in-doubt phase start"); + Thread.Sleep(SleepTime); + if (FailInSecondPhase) + throw new InvalidOperationException("Asked to fail"); + _log.Info(Name + ": in-doubt phase, calling done"); + enlistment.Done(); + Thread.Sleep(SleepTime); + _log.Info(Name + ": in-doubt phase end"); + } + + private void Current_TransactionCompleted(object sender, TransactionEventArgs e) + { + _log.Info(Name + ": transaction completed start"); + Thread.Sleep(SleepTime); + _log.Info(Name + ": transaction completed middle"); + Thread.Sleep(SleepTime); + _log.Info(Name + ": transaction completed end"); + } + + private class EnlistSinglePhaseResource : EnlistResource, ISinglePhaseNotification + { + public EnlistSinglePhaseResource(bool shouldRollBack, string name, bool inDoubt, bool failInSecondPhase) : + base(shouldRollBack, "Single phase " + name, inDoubt, failInSecondPhase) + { + } + + public void SinglePhaseCommit(SinglePhaseEnlistment singlePhaseEnlistment) + { + _log.Info(Name + ": transaction single phase start"); + Thread.Sleep(SleepTime); + if (ShouldRollBack) + { + _log.Info(Name + ": transaction single phase, calling aborted"); + singlePhaseEnlistment.Aborted(); + } + else if (ShouldGoInDoubt) + { + _log.Info(Name + ": transaction single phase, calling in doubt"); + singlePhaseEnlistment.InDoubt(); + } + else + { + _log.Info(Name + ": transaction single phase, calling committed"); + singlePhaseEnlistment.Committed(); + } + Thread.Sleep(SleepTime); + _log.Info(Name + ": transaction single phase end"); + } + } + } + + #endregion + } +} diff --git a/src/NHibernate.Test/SystemTransactions/SystemTransactionFixture.cs b/src/NHibernate.Test/SystemTransactions/SystemTransactionFixture.cs new file mode 100644 index 00000000000..18e456663b2 --- /dev/null +++ b/src/NHibernate.Test/SystemTransactions/SystemTransactionFixture.cs @@ -0,0 +1,542 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Transactions; +using NHibernate.Cfg; +using NHibernate.Engine; +using NHibernate.Linq; +using NHibernate.Test.TransactionTest; +using NUnit.Framework; + +namespace NHibernate.Test.SystemTransactions +{ + [TestFixture] + public class SystemTransactionFixture : SystemTransactionFixtureBase + { + protected override bool UseConnectionOnSystemTransactionPrepare => true; + protected override bool AutoJoinTransaction => true; + + [Test] + public void WillNotCrashOnPrepareFailure() + { + IgnoreIfUnsupported(false); + var tx = new TransactionScope(); + var disposeCalled = false; + try + { + using (var s = OpenSession()) + { + s.Save(new Person { NotNullData = null }); // Cause a SQL not null constraint violation. + } + + tx.Complete(); + disposeCalled = true; + Assert.Throws(tx.Dispose, "Scope disposal has not rollback and throw."); + } + finally + { + if (!disposeCalled) + { + try + { + tx.Dispose(); + } + catch + { + // Ignore, if disposed has not been called, another exception has occurred in the try and + // we should avoid overriding it by the disposal failure. + } + } + } + } + + [Theory] + public void CanRollbackTransactionFromScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope()) + using (var s = OpenSession()) + { + s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + // No Complete call for triggering rollback. + } + + AssertNoPersons(); + } + + [Theory] + [Description("rollback inside nh-session-scope should not commit save and the transaction should be aborted.")] + public void TransactionInsertWithRollBackFromScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new Person(); + s.Save(person); + + if (explicitFlush) + s.Flush(); + } + // No Complete call for triggering rollback. + } + AssertNoPersons(); + } + + [Theory] + [Description(@"Two session in two txscope + (without an explicit NH transaction) + and with a rollback in the second and a rollback outside nh-session-scope.")] + public void TransactionInsertLoadWithRollBackFromScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object savedId; + var createdAt = DateTime.Today; + using (var txscope = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new Person { CreatedAt = createdAt }; + savedId = s.Save(person); + + if (explicitFlush) + s.Flush(); + } + txscope.Complete(); + } + + using (new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = s.Get(savedId); + person.CreatedAt = createdAt.AddMonths(-1); + + if (explicitFlush) + s.Flush(); + } + + // No Complete call for triggering rollback. + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(createdAt, s.Get(savedId).CreatedAt, "Entity update was not rollback-ed."); + } + } + + [Theory] + public void CanDeleteItem(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + object id; + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + id = s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + } + + using (var s = OpenSession()) + using (s.BeginTransaction()) + { + Assert.AreEqual(1, s.Query().Count(), "Entity not found in database."); + } + + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + s.Delete(s.Get(id)); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + } + + AssertNoPersons(); + } + + [Theory] + public void CanUseSessionWithManyScopes(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (var s = WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + // Acquire the connection + var count = s.Query().Count(); + Assert.That(count, Is.EqualTo(0), "Unexpected initial entity count."); + tx.Complete(); + } + + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = s.Query().Count(); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after committed insert."); + tx.Complete(); + } + + using (new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + + // No complete for rollback-ing. + } + + // Do not reuse the session after a rollback, its state does not allow it. + // http://nhibernate.info/doc/nhibernate-reference/manipulatingdata.html#manipulatingdata-endingsession-commit + } + + using (var s = OpenSession()) + { + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + var count = s.Query().Count(); + Assert.That(count, Is.EqualTo(1), "Unexpected entity count after rollback-ed insert."); + tx.Complete(); + } + } + } + + [Theory] + public void CanUseSessionOutsideOfScopeAfterScope(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + using (var s = WithOptions().ConnectionReleaseMode(ConnectionReleaseMode.OnClose).OpenSession()) + { + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + + tx.Complete(); + } + var count = 0; + Assert.DoesNotThrow(() => count = s.Query().Count(), "Failed using the session after scope."); + if (count != 1) + // We are not testing that here, so just issue a warning. Do not use DodgeTransactionCompletionDelayIfRequired + // before previous assert. We want to ascertain the session is usable in any cases. + Assert.Warn("Unexpected entity count: {0} instead of {1}. The transaction seems to have a delayed commit.", count, 1); + } + } + + [Theory] + [Description("Do not fail, but warn in case a delayed after scope disposal commit is made.")] + public void DelayedTransactionCompletion(bool explicitFlush) + { + IgnoreIfUnsupported(explicitFlush); + for (var i = 1; i <= 10; i++) + { + // Isolation level must be read committed on the control session: reading twice while expecting some data insert + // in between due to a late commit. Repeatable read would block and read uncommitted would see the uncommitted data. + using (var controlSession = OpenSession()) + using (controlSession.BeginTransaction(System.Data.IsolationLevel.ReadCommitted)) + { + // We want to have the control session as ready to query as possible, thus beginning its + // transaction early for acquiring the connection, even if we will not use it before + // below scope completion. + + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + s.Save(new Person()); + + if (explicitFlush) + s.Flush(); + } + tx.Complete(); + } + + var count = controlSession.Query().Count(); + if (count != i) + { + Thread.Sleep(100); + var countSecondTry = controlSession.Query().Count(); + Assert.Warn($"Unexpected entity count: {count} instead of {i}. " + + "This may mean current data provider has a delayed commit, occurring after scope disposal. " + + $"After waiting, count is now {countSecondTry}. "); + break; + } + } + } + } + + [Test] + public void FlushFromTransactionAppliesToDisposedSharingSession() + { + IgnoreIfUnsupported(false); + + var flushOrder = new List(); + using (var s = OpenSession(new TestInterceptor(0, flushOrder))) + { + var builder = s.SessionWithOptions().Connection(); + + using (var t = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + var p1 = new Person(); + var p2 = new Person(); + var p3 = new Person(); + var p4 = new Person(); + + using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) + { + if (!AutoJoinTransaction) + s1.JoinTransaction(); + s1.Save(p1); + } + using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) + { + if (!AutoJoinTransaction) + s2.JoinTransaction(); + s2.Save(p2); + using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)) + .OpenSession()) + { + if (!AutoJoinTransaction) + s3.JoinTransaction(); + s3.Save(p3); + } + } + s.Save(p4); + t.Complete(); + } + } + + Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); + + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + Assert.That(s.Query().Count(), Is.EqualTo(4)); + t.Commit(); + } + } + + [Test] + public void FlushFromTransactionAppliesToSharingSession() + { + IgnoreIfUnsupported(false); + + var flushOrder = new List(); + using (var s = OpenSession(new TestInterceptor(0, flushOrder))) + { + var builder = s.SessionWithOptions().Connection(); + + using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) + using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) + using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)).OpenSession()) + using (var t = new TransactionScope()) + { + if (!AutoJoinTransaction) + { + s.JoinTransaction(); + s1.JoinTransaction(); + s2.JoinTransaction(); + s3.JoinTransaction(); + } + var p1 = new Person(); + var p2 = new Person(); + var p3 = new Person(); + var p4 = new Person(); + s1.Save(p1); + s2.Save(p2); + s3.Save(p3); + s.Save(p4); + t.Complete(); + } + } + + Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); + + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + Assert.That(s.Query().Count(), Is.EqualTo(4)); + t.Commit(); + } + } + + // Taken and adjusted from NH1632 When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_level_cache + [Test] + public void WhenCommittingItemsAfterSessionDisposalWillAddThemTo2ndLevelCache() + { + int id; + const string notNullData = "test"; + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = new CacheablePerson { NotNullData = notNullData }; + s.Save(person); + id = person.Id; + + s.Flush(); + } + tx.Complete(); + } + + using (var tx = new TransactionScope()) + { + using (var s = OpenSession()) + { + var person = s.Load(id); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + tx.Complete(); + } + + // Closing the connection to ensure we can't actually use it. + var connection = Sfi.ConnectionProvider.GetConnection(); + Sfi.ConnectionProvider.CloseConnection(connection); + + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. + using (var s = WithOptions().Connection(connection).OpenSession()) + { + CacheablePerson person = null; + Assert.DoesNotThrow(() => person = s.Load(id), "Failed loading entity from second level cache."); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + } + + [Test] + public void DoNotDeadlockOnAfterTransactionWait() + { + var interceptor = new AfterTransactionWaitingInterceptor(); + using (var s = WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + s.Flush(); + tx.Complete(); + } + Assert.That(interceptor.Exception, Is.Null); + } + + [Test] + public void EnforceConnectionUsageRulesOnTransactionCompletion() + { + var interceptor = new TransactionCompleteUsingConnectionInterceptor(); + // Do not invert session and scope, it would cause an expected failure when + // UseConnectionOnSystemTransactionEvents is false, due to the session being closed. + using (var s = WithOptions().Interceptor(interceptor).OpenSession()) + using (var tx = new TransactionScope()) + { + if (!AutoJoinTransaction) + s.JoinTransaction(); + s.Save(new Person()); + + s.Flush(); + tx.Complete(); + } + + if (UseConnectionOnSystemTransactionPrepare) + { + Assert.That(interceptor.BeforeException, Is.Null); + } + else + { + Assert.That(interceptor.BeforeException, Is.TypeOf()); + } + // Currently always forbidden, whatever UseConnectionOnSystemTransactionEvents. + Assert.That(interceptor.AfterException, Is.TypeOf()); + } + + [Test] + public void AdditionalJoinDoesNotThrow() + { + using (new TransactionScope()) + using (var s = OpenSession()) + { + Assert.DoesNotThrow(() => s.JoinTransaction()); + } + } + } + + [TestFixture] + public class SystemTransactionWithoutConnectionFromPrepareFixture : SystemTransactionFixture + { + protected override bool UseConnectionOnSystemTransactionPrepare => false; + } + + [TestFixture] + public class SystemTransactionWithoutConnectionAutoEnlistmentFixture : SystemTransactionFixture + { + protected override void Configure(Configuration configuration) + { + base.Configure(configuration); + DisableConnectionAutoEnlist(configuration); + } + + protected override bool AppliesTo(ISessionFactoryImplementor factory) + => base.AppliesTo(factory) && factory.ConnectionProvider.Driver.SupportsEnlistmentWhenAutoEnlistmentIsDisabled; + } + + [TestFixture] + public class SystemTransactionWithoutAutoJoinTransaction : SystemTransactionWithoutConnectionAutoEnlistmentFixture + { + protected override bool AutoJoinTransaction => false; + + [Test] + public void SessionIsNotEnlisted() + { + using (new TransactionScope()) + // Dodge the OpenSession override which call JoinTransaction by calling WithOptions(). + using (var s = WithOptions().OpenSession()) + { + Assert.That(s.GetSessionImplementation().TransactionContext, Is.Null); + } + } + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/SystemTransactions/SystemTransactionFixtureBase.cs b/src/NHibernate.Test/SystemTransactions/SystemTransactionFixtureBase.cs new file mode 100644 index 00000000000..6a13a609c2a --- /dev/null +++ b/src/NHibernate.Test/SystemTransactions/SystemTransactionFixtureBase.cs @@ -0,0 +1,146 @@ +using System; +using System.Text.RegularExpressions; +using NHibernate.Cfg; +using NHibernate.Driver; +using NHibernate.Engine; +using NHibernate.Test.TransactionTest; +using NHibernate.Util; +using NUnit.Framework; + +namespace NHibernate.Test.SystemTransactions +{ + public abstract class SystemTransactionFixtureBase : TransactionFixtureBase + { + protected override bool AppliesTo(ISessionFactoryImplementor factory) + => factory.ConnectionProvider.Driver.SupportsSystemTransactions && base.AppliesTo(factory); + + protected abstract bool UseConnectionOnSystemTransactionPrepare { get; } + protected abstract bool AutoJoinTransaction { get; } + + protected override void Configure(Configuration configuration) + { + base.Configure(configuration); + configuration + .SetProperty( + Cfg.Environment.UseConnectionOnSystemTransactionPrepare, + UseConnectionOnSystemTransactionPrepare.ToString()); + } + + protected void DisableConnectionAutoEnlist(Configuration configuration) + { + var connectionString = configuration.GetProperty(Cfg.Environment.ConnectionString); + var autoEnlistmentKeyword = "Enlist"; + var autoEnlistmentKeywordPattern = autoEnlistmentKeyword; + if (configuration.GetDerivedProperties().TryGetValue(Cfg.Environment.ConnectionDriver, out var driver) && + typeof(MySqlDataDriver).IsAssignableFrom(ReflectHelper.ClassForName(driver))) + { + autoEnlistmentKeyword = "AutoEnlist"; + autoEnlistmentKeywordPattern = "Auto ?Enlist"; + } + // Purge any previous enlist + connectionString = Regex.Replace( + connectionString, $"[^;\"a-zA-Z]*{autoEnlistmentKeywordPattern}=[^;\"]*", string.Empty, + RegexOptions.IgnoreCase | RegexOptions.Multiline); + connectionString += $";{autoEnlistmentKeyword}=false;"; + configuration.SetProperty(Cfg.Environment.ConnectionString, connectionString); + } + + protected void IgnoreIfUnsupported(bool explicitFlush) + { + Assume.That( + new[] { explicitFlush, UseConnectionOnSystemTransactionPrepare }, + Has.Some.EqualTo(true), + "Implicit flush cannot work without using connection from system transaction prepare phase"); + } + + /// + /// Open a session, manually enlisting it into ambient transaction if there is one. + /// + /// An newly opened session. + protected override ISession OpenSession() + { + if (AutoJoinTransaction) + return base.OpenSession(); + + var session = Sfi.WithOptions().AutoJoinTransaction(false).OpenSession(); + if (System.Transactions.Transaction.Current != null) + session.JoinTransaction(); + return session; + } + + /// + /// WithOptions having already set up AutoJoinTransaction() + /// according to the fixture property. + /// + /// A session builder. + protected ISessionBuilder WithOptions() + { + return Sfi.WithOptions().AutoJoinTransaction(AutoJoinTransaction); + } + + public class AfterTransactionWaitingInterceptor : EmptyInterceptor + { + private ISession _session; + + public Exception Exception { get; private set; } + + public override void SetSession(ISession session) + { + _session = session; + } + + public override void AfterTransactionCompletion(ITransaction tx) + { + try + { + // Simulate an action causing a Wait + _session.GetSessionImplementation().TransactionContext?.Wait(); + } + catch (Exception ex) + { + Exception = ex; + throw; + } + } + } + + public class TransactionCompleteUsingConnectionInterceptor : EmptyInterceptor + { + private ISession _session; + + public Exception BeforeException { get; private set; } + public Exception AfterException { get; private set; } + + public override void SetSession(ISession session) + { + _session = session; + } + + public override void BeforeTransactionCompletion(ITransaction tx) + { + try + { + // Simulate an action causing a connection usage. + _session.Connection.ToString(); + } + catch (Exception ex) + { + BeforeException = ex; + } + } + + public override void AfterTransactionCompletion(ITransaction tx) + { + try + { + // Simulate an action causing a connection usage. + _session.Connection.ToString(); + } + catch (Exception ex) + { + AfterException = ex; + } + } + } + } +} \ No newline at end of file diff --git a/src/NHibernate.Test/SystemTransactions/TransactionFixture.cs b/src/NHibernate.Test/SystemTransactions/TransactionFixture.cs deleted file mode 100644 index de5dd5eb7c6..00000000000 --- a/src/NHibernate.Test/SystemTransactions/TransactionFixture.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Transactions; -using NHibernate.Linq; -using NHibernate.Test.TransactionTest; -using NUnit.Framework; - -namespace NHibernate.Test.SystemTransactions -{ - [TestFixture] - public class TransactionFixture : TransactionFixtureBase - { - [Test] - public void CanUseSystemTransactionsToCommit() - { - int identifier; - using(ISession session = Sfi.OpenSession()) - using(TransactionScope tx = new TransactionScope()) - { - var s = new Person(); - session.Save(s); - identifier = s.Id; - tx.Complete(); - } - - using (ISession session = Sfi.OpenSession()) - using (TransactionScope tx = new TransactionScope()) - { - var w = session.Get(identifier); - Assert.IsNotNull(w); - session.Delete(w); - tx.Complete(); - } - } - - [Test] - public void FlushFromTransactionAppliesToDisposedSharingSession() - { - var flushOrder = new List(); - using (var s = OpenSession(new TestInterceptor(0, flushOrder))) - { - var builder = s.SessionWithOptions().Connection(); - - using (var t = new TransactionScope()) - { - var p1 = new Person(); - var p2 = new Person(); - var p3 = new Person(); - var p4 = new Person(); - - using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) - s1.Save(p1); - using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) - { - s2.Save(p2); - using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)).OpenSession()) - s3.Save(p3); - } - s.Save(p4); - t.Complete(); - } - } - - Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); - - using (var s = OpenSession()) - using (var t = s.BeginTransaction()) - { - Assert.That(s.Query().Count(), Is.EqualTo(4)); - t.Commit(); - } - } - - [Test] - public void FlushFromTransactionAppliesToSharingSession() - { - var flushOrder = new List(); - using (var s = OpenSession(new TestInterceptor(0, flushOrder))) - { - var builder = s.SessionWithOptions().Connection(); - - using (var s1 = builder.Interceptor(new TestInterceptor(1, flushOrder)).OpenSession()) - using (var s2 = builder.Interceptor(new TestInterceptor(2, flushOrder)).OpenSession()) - using (var s3 = s2.SessionWithOptions().Connection().Interceptor(new TestInterceptor(3, flushOrder)).OpenSession()) - using (var t = new TransactionScope()) - { - var p1 = new Person(); - var p2 = new Person(); - var p3 = new Person(); - var p4 = new Person(); - s1.Save(p1); - s2.Save(p2); - s3.Save(p3); - s.Save(p4); - t.Complete(); - } - } - - Assert.That(flushOrder, Is.EqualTo(new[] { 0, 1, 2, 3 })); - - using (var s = OpenSession()) - using (var t = s.BeginTransaction()) - { - Assert.That(s.Query().Count(), Is.EqualTo(4)); - t.Commit(); - } - } - } -} \ No newline at end of file diff --git a/src/NHibernate.Test/SystemTransactions/TransactionNotificationFixture.cs b/src/NHibernate.Test/SystemTransactions/TransactionNotificationFixture.cs index 340c6f8dfb6..d37e897681a 100644 --- a/src/NHibernate.Test/SystemTransactions/TransactionNotificationFixture.cs +++ b/src/NHibernate.Test/SystemTransactions/TransactionNotificationFixture.cs @@ -1,38 +1,53 @@ -using System; using System.Collections; -using System.Data.Common; -using System.Threading; using System.Transactions; +using NHibernate.Cfg; using NUnit.Framework; namespace NHibernate.Test.SystemTransactions { - [TestFixture] public class TransactionNotificationFixture : TestCase { protected override IList Mappings + => new string[] { }; + + protected virtual bool UseConnectionOnSystemTransactionPrepare => true; + + protected override void Configure(Configuration configuration) { - get { return new string[] {}; } + configuration.SetProperty( + Environment.UseConnectionOnSystemTransactionPrepare, + UseConnectionOnSystemTransactionPrepare.ToString()); } - [Test] public void NoTransaction() { var interceptor = new RecordingInterceptor(); - using (Sfi.WithOptions().Interceptor(interceptor).OpenSession()) + using (Sfi.WithOptions().Interceptor(interceptor).OpenSession()) { } + Assert.AreEqual(0, interceptor.afterTransactionBeginCalled); + Assert.AreEqual(0, interceptor.beforeTransactionCompletionCalled); + Assert.AreEqual(0, interceptor.afterTransactionCompletionCalled); + } + + [Test] + public void TransactionDisabled() + { + var interceptor = new RecordingInterceptor(); + using (var ts = new TransactionScope()) + using (Sfi.WithOptions().Interceptor(interceptor).AutoJoinTransaction(false).OpenSession()) { - Assert.AreEqual(0, interceptor.afterTransactionBeginCalled); - Assert.AreEqual(0, interceptor.beforeTransactionCompletionCalled); - Assert.AreEqual(0, interceptor.afterTransactionCompletionCalled); + ts.Complete(); } + Assert.AreEqual(0, interceptor.afterTransactionBeginCalled); + Assert.AreEqual(0, interceptor.beforeTransactionCompletionCalled); + Assert.AreEqual(0, interceptor.afterTransactionCompletionCalled); } [Test] public void AfterBegin() { var interceptor = new RecordingInterceptor(); - using (new TransactionScope()) + using (new TransactionScope()) using (Sfi.WithOptions().Interceptor(interceptor).OpenSession()) { Assert.AreEqual(1, interceptor.afterTransactionBeginCalled); @@ -46,7 +61,7 @@ public void Complete() { var interceptor = new RecordingInterceptor(); ISession session; - using(var scope = new TransactionScope()) + using (var scope = new TransactionScope()) { session = Sfi.WithOptions().Interceptor(interceptor).OpenSession(); scope.Complete(); @@ -54,7 +69,7 @@ public void Complete() session.Dispose(); Assert.AreEqual(1, interceptor.beforeTransactionCompletionCalled); Assert.AreEqual(1, interceptor.afterTransactionCompletionCalled); - + } [Test] @@ -153,18 +168,18 @@ public void ShouldNotifyAfterDistributedTransaction(bool doCommit) [Theory] public void ShouldNotifyAfterDistributedTransactionWithOwnConnection(bool doCommit) { - // Note: For distributed transaction, calling Close() on the session isn't + // Note: For system transaction, calling Close() on the session isn't // supported, so we don't need to test that scenario. var interceptor = new RecordingInterceptor(); - ISession s1 = null; + ISession s1; - using (var tx = new TransactionScope()) + var ownConnection1 = Sfi.ConnectionProvider.GetConnection(); + try { - var ownConnection1 = Sfi.ConnectionProvider.GetConnection(); - - try + using (var tx = new TransactionScope()) { + ownConnection1.EnlistTransaction(System.Transactions.Transaction.Current); using (s1 = Sfi.WithOptions().Connection(ownConnection1).Interceptor(interceptor).OpenSession()) { s1.CreateCriteria().List(); @@ -173,17 +188,23 @@ public void ShouldNotifyAfterDistributedTransactionWithOwnConnection(bool doComm if (doCommit) tx.Complete(); } - finally - { - Sfi.ConnectionProvider.CloseConnection(ownConnection1); - } + } + finally + { + Sfi.ConnectionProvider.CloseConnection(ownConnection1); } - // Transaction completion may happen asynchronously, so allow some delay. - Assert.That(() => s1.IsOpen, Is.False.After(500, 100)); + // Transaction completion may happen asynchronously, so allow some delay. Odbc promotes + // this test to distributed and have that delay, by example. + Assert.That(() => s1.IsOpen, Is.False.After(500, 100), "Session not closed."); Assert.That(interceptor.afterTransactionCompletionCalled, Is.EqualTo(1)); } + } + [TestFixture] + public class TransactionWithoutConnectionFromPrepareNotificationFixture : TransactionNotificationFixture + { + protected override bool UseConnectionOnSystemTransactionPrepare => false; } } \ No newline at end of file diff --git a/src/NHibernate.Test/TestCase.cs b/src/NHibernate.Test/TestCase.cs index c13d5953597..b8a606ea050 100644 --- a/src/NHibernate.Test/TestCase.cs +++ b/src/NHibernate.Test/TestCase.cs @@ -148,31 +148,44 @@ public void TearDown() { var testResult = TestContext.CurrentContext.Result; var fail = false; + var testOwnTearDownDone = false; string badCleanupMessage = null; try { try { OnTearDown(); + testOwnTearDownDone = true; } finally { - var wereClosed = _sessionFactory.CheckSessionsWereClosed(); - var wasCleaned = CheckDatabaseWasCleaned(); - var wereConnectionsClosed = CheckConnectionsWereClosed(); - fail = !wereClosed || !wasCleaned || !wereConnectionsClosed; - - if (fail) + try { - badCleanupMessage = "Test didn't clean up after itself. session closed: " + wereClosed + "; database cleaned: " + - wasCleaned - + "; connection closed: " + wereConnectionsClosed; - if (testResult != null && testResult.Outcome.Status == TestStatus.Failed) + var wereClosed = _sessionFactory.CheckSessionsWereClosed(); + var wasCleaned = CheckDatabaseWasCleaned(); + var wereConnectionsClosed = CheckConnectionsWereClosed(); + fail = !wereClosed || !wasCleaned || !wereConnectionsClosed; + + if (fail) { - // Avoid hiding a test failure (asserts are usually not hidden, but other exception would be). - badCleanupMessage = GetCombinedFailureMessage(testResult, badCleanupMessage, null); + badCleanupMessage = "Test didn't clean up after itself. session closed: " + wereClosed + "; database cleaned: " + + wasCleaned + + "; connection closed: " + wereConnectionsClosed; + if (testResult != null && testResult.Outcome.Status == TestStatus.Failed) + { + // Avoid hiding a test failure (asserts are usually not hidden, but other exception would be). + badCleanupMessage = GetCombinedFailureMessage(testResult, badCleanupMessage, null); + } } } + catch (Exception ex) + { + if (testOwnTearDownDone) + throw; + + // Do not hide the test own teardown failure. + log.Error("TearDown cleanup failure, while test own teardown has failed. Logging cleanup failure", ex); + } } } catch (Exception ex) diff --git a/src/NHibernate.Test/TestDialect.cs b/src/NHibernate.Test/TestDialect.cs index 7b22a446698..6d7fe8b4662 100644 --- a/src/NHibernate.Test/TestDialect.cs +++ b/src/NHibernate.Test/TestDialect.cs @@ -30,15 +30,6 @@ public TestDialect(Dialect.Dialect dialect) public virtual bool SupportsOperatorSome => true; public virtual bool SupportsLocate => true; - public virtual bool SupportsDistributedTransactions => true; - - /// - /// Whether two transactions can be run at the same time. For example, with SQLite - /// the database is locked when one transaction is run, so running a second transaction - /// will cause a "database is locked" error message. - /// - public virtual bool SupportsConcurrentTransactions => true; - public virtual bool SupportsFullJoin => true; public virtual bool HasBrokenDecimalType => false; diff --git a/src/NHibernate.Test/TestDialects/FirebirdTestDialect.cs b/src/NHibernate.Test/TestDialects/FirebirdTestDialect.cs index e7f672cacb9..f4eccb87989 100644 --- a/src/NHibernate.Test/TestDialects/FirebirdTestDialect.cs +++ b/src/NHibernate.Test/TestDialects/FirebirdTestDialect.cs @@ -6,8 +6,6 @@ public FirebirdTestDialect(Dialect.Dialect dialect) : base(dialect) { } - public override bool SupportsDistributedTransactions => false; - public override bool SupportsComplexExpressionInGroupBy => false; } -} \ No newline at end of file +} diff --git a/src/NHibernate.Test/TestDialects/MsSqlCe40TestDialect.cs b/src/NHibernate.Test/TestDialects/MsSqlCe40TestDialect.cs index 7e1e5ab6162..8f384459d76 100644 --- a/src/NHibernate.Test/TestDialects/MsSqlCe40TestDialect.cs +++ b/src/NHibernate.Test/TestDialects/MsSqlCe40TestDialect.cs @@ -6,10 +6,6 @@ public MsSqlCe40TestDialect(Dialect.Dialect dialect) : base(dialect) { } - public override bool SupportsDistributedTransactions => false; - - public override bool SupportsConcurrentTransactions => false; - public override bool SupportsFullJoin => false; public override bool SupportsComplexExpressionInGroupBy => false; @@ -30,4 +26,4 @@ public MsSqlCe40TestDialect(Dialect.Dialect dialect) : base(dialect) public override bool SupportsEmptyInserts => false; } -} \ No newline at end of file +} diff --git a/src/NHibernate.Test/TestDialects/MySQL5TestDialect.cs b/src/NHibernate.Test/TestDialects/MySQL5TestDialect.cs deleted file mode 100644 index 5798bce0f81..00000000000 --- a/src/NHibernate.Test/TestDialects/MySQL5TestDialect.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace NHibernate.Test.TestDialects -{ - public class MySQL5TestDialect : TestDialect - { - public MySQL5TestDialect(Dialect.Dialect dialect) - : base(dialect) - { - } - - public override bool SupportsDistributedTransactions => false; - } -} diff --git a/src/NHibernate.Test/TestDialects/SQLiteTestDialect.cs b/src/NHibernate.Test/TestDialects/SQLiteTestDialect.cs index 13cd6bd8841..22c65d41f2c 100644 --- a/src/NHibernate.Test/TestDialects/SQLiteTestDialect.cs +++ b/src/NHibernate.Test/TestDialects/SQLiteTestDialect.cs @@ -27,16 +27,6 @@ public override bool SupportsLocate get { return false; } } - public override bool SupportsDistributedTransactions - { - get { return false; } - } - - public override bool SupportsConcurrentTransactions - { - get { return false; } - } - public override bool SupportsFullJoin { get { return false; } diff --git a/src/NHibernate.Test/TransactionTest/Person.cs b/src/NHibernate.Test/TransactionTest/Person.cs index 39ad3f19626..61431a968ac 100644 --- a/src/NHibernate.Test/TransactionTest/Person.cs +++ b/src/NHibernate.Test/TransactionTest/Person.cs @@ -2,10 +2,18 @@ namespace NHibernate.Test.TransactionTest { - public class Person + public class PersonBase { public virtual int Id { get; set; } public virtual DateTime CreatedAt { get; set; } = DateTime.Now; + + public virtual string NotNullData { get; set; } = "not-null"; } + + public class Person : PersonBase + { } + + public class CacheablePerson : PersonBase + { } } \ No newline at end of file diff --git a/src/NHibernate.Test/TransactionTest/Person.hbm.xml b/src/NHibernate.Test/TransactionTest/Person.hbm.xml index 49c9ffc20e1..300b418456f 100644 --- a/src/NHibernate.Test/TransactionTest/Person.hbm.xml +++ b/src/NHibernate.Test/TransactionTest/Person.hbm.xml @@ -8,5 +8,16 @@ + + + + + + + + + + + diff --git a/src/NHibernate.Test/TransactionTest/TransactionFixture.cs b/src/NHibernate.Test/TransactionTest/TransactionFixture.cs index 3c3d2e54a09..7e49744ed50 100644 --- a/src/NHibernate.Test/TransactionTest/TransactionFixture.cs +++ b/src/NHibernate.Test/TransactionTest/TransactionFixture.cs @@ -172,5 +172,45 @@ public void FlushFromTransactionAppliesToSharingSession() t.Commit(); } } + + // Taken and adjusted from NH1632 When_commiting_items_in_DTC_transaction_will_add_items_to_2nd_level_cache + [Test] + public void WhenCommittingItemsWillAddThemTo2ndLevelCache() + { + int id; + const string notNullData = "test"; + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + var person = new CacheablePerson { NotNullData = notNullData }; + s.Save(person); + id = person.Id; + + t.Commit(); + } + + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + var person = s.Load(id); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + t.Commit(); + } + + // Closing the connection to ensure we can't actually use it. + var connection = Sfi.ConnectionProvider.GetConnection(); + Sfi.ConnectionProvider.CloseConnection(connection); + + // The session is supposed to succeed because the second level cache should have the + // entity to load, allowing the session to not use the connection at all. + // Will fail if a transaction manager tries to enlist user supplied connection. Do + // not add a transaction scope below. + using (var s = Sfi.WithOptions().Connection(connection).OpenSession()) + { + CacheablePerson person = null; + Assert.DoesNotThrow(() => person = s.Load(id), "Failed loading entity from second level cache."); + Assert.That(person.NotNullData, Is.EqualTo(notNullData)); + } + } } } \ No newline at end of file diff --git a/src/NHibernate.Test/TransactionTest/TransactionFixtureBase.cs b/src/NHibernate.Test/TransactionTest/TransactionFixtureBase.cs index 2ad374951cd..16c21294c78 100644 --- a/src/NHibernate.Test/TransactionTest/TransactionFixtureBase.cs +++ b/src/NHibernate.Test/TransactionTest/TransactionFixtureBase.cs @@ -1,5 +1,13 @@ using System.Collections; using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using NHibernate.Cache; +using NHibernate.Cfg; +using NHibernate.Cfg.MappingSchema; +using NHibernate.Linq; +using NHibernate.Tool.hbm2ddl; +using NUnit.Framework; namespace NHibernate.Test.TransactionTest { @@ -9,6 +17,32 @@ public abstract class TransactionFixtureBase : TestCase protected override string MappingsAssembly => "NHibernate.Test"; + protected override void Configure(Configuration configuration) + { + configuration + .SetProperty(Environment.UseSecondLevelCache, "true") + .SetProperty(Environment.CacheProvider, typeof(HashtableCacheProvider).AssemblyQualifiedName); + } + + protected override void CreateSchema() + { + // Copied from Configure method. + var config = new Configuration(); + if (TestConfigurationHelper.hibernateConfigFile != null) + config.Configure(TestConfigurationHelper.hibernateConfigFile); + + // Our override so we can set nullability on database column without NHibernate knowing about it. + config.BeforeBindMapping += BeforeBindMapping; + + // Copied from AddMappings methods. + var assembly = Assembly.Load(MappingsAssembly); + foreach (var file in Mappings) + config.AddResource(MappingsAssembly + "." + file, assembly); + + // Copied from CreateSchema method, but we use our own config. + new SchemaExport(config).Create(false, true); + } + protected override void OnTearDown() { using (var s = OpenSession()) @@ -19,6 +53,23 @@ protected override void OnTearDown() } } + private void BeforeBindMapping(object sender, BindMappingEventArgs e) + { + var prop = e.Mapping.RootClasses[0].Properties.OfType().Single(p => p.Name == "NotNullData"); + prop.notnull = true; + prop.notnullSpecified = true; + } + + protected void AssertNoPersons() + { + using (var s = OpenSession()) + using (var t = s.BeginTransaction()) + { + Assert.AreEqual(0, s.Query().Count(), "Entities found in database."); + t.Commit(); + } + } + public class TestInterceptor : EmptyInterceptor { private readonly int _numero; diff --git a/src/NHibernate/AdoNet/ConnectionManager.cs b/src/NHibernate/AdoNet/ConnectionManager.cs index 23cc614698a..e9881c737fd 100644 --- a/src/NHibernate/AdoNet/ConnectionManager.cs +++ b/src/NHibernate/AdoNet/ConnectionManager.cs @@ -13,41 +13,40 @@ namespace NHibernate.AdoNet /// Manages the database connection and transaction for an . /// /// - /// This class corresponds to LogicalConnectionImplementor and JdbcCoordinator in Hibernate, - /// combined. + /// This class corresponds to LogicalConnectionImplementor and JdbcCoordinator + /// in Hibernate, combined. /// [Serializable] public partial class ConnectionManager : ISerializable, IDeserializationCallback { - private static readonly IInternalLogger log = LoggerProvider.LoggerFor(typeof(ConnectionManager)); - - public interface Callback - { - void ConnectionOpened(); - void ConnectionCleanedUp(); - bool IsTransactionInProgress { get; } - } + private static readonly IInternalLogger _log = LoggerProvider.LoggerFor(typeof(ConnectionManager)); [NonSerialized] - private DbConnection connection; + private DbConnection _connection; + [NonSerialized] + private DbConnection _backupConnection; + [NonSerialized] + private System.Transactions.Transaction _currentSystemTransaction; + [NonSerialized] + private System.Transactions.Transaction _backupCurrentSystemTransaction; // Whether we own the connection, i.e. connect and disconnect automatically. - private bool ownConnection; + private bool _ownConnection; [NonSerialized] - private ITransaction transaction; + private ITransaction _transaction; [NonSerialized] - private IBatcher batcher; + private IBatcher _batcher; - private readonly ISessionImplementor session; - private readonly ConnectionReleaseMode connectionReleaseMode; - private readonly IInterceptor interceptor; + private readonly ConnectionReleaseMode _connectionReleaseMode; + private readonly IInterceptor _interceptor; + [NonSerialized] private readonly List _dependentSessions = new List(); /// /// The session responsible for the lifecycle of the connection manager. /// - public ISessionImplementor Session => session; + public ISessionImplementor Session { get; } /// /// The sessions using the connection manager of the session responsible for it. @@ -57,22 +56,39 @@ public interface Callback [NonSerialized] private bool _releasesEnabled = true; - private bool flushingFromDtcTransaction; + [NonSerialized] + private bool _processingFromSystemTransaction; + [NonSerialized] + private bool _allowConnectionUsage = true; + // Do we need to release the current connection instead of yielding it? + [NonSerialized] + private bool _connectionReleaseRequired; + // Do we need to explicitly enlist the current connection before yielding it? + [NonSerialized] + private bool _connectionEnlistmentRequired; + + /// + /// when the connection manager is being used from system transaction completion events, + /// otherwise. + /// + public bool ProcessingFromSystemTransaction => _processingFromSystemTransaction; public ConnectionManager( ISessionImplementor session, DbConnection suppliedConnection, ConnectionReleaseMode connectionReleaseMode, - IInterceptor interceptor) + IInterceptor interceptor, + bool shouldAutoJoinTransaction) { - this.session = session; - connection = suppliedConnection; - this.connectionReleaseMode = connectionReleaseMode; + Session = session; + _connection = suppliedConnection; + _connectionReleaseMode = connectionReleaseMode; - this.interceptor = interceptor; - batcher = session.Factory.Settings.BatcherFactory.CreateBatcher(this, interceptor); + _interceptor = interceptor; + _batcher = session.Factory.Settings.BatcherFactory.CreateBatcher(this, interceptor); - ownConnection = suppliedConnection == null; + _ownConnection = suppliedConnection == null; + ShouldAutoJoinTransaction = shouldAutoJoinTransaction; } public void AddDependentSession(ISessionImplementor session) @@ -85,133 +101,157 @@ public void RemoveDependentSession(ISessionImplementor session) _dependentSessions.Remove(session); } + public bool IsInActiveExplicitTransaction + => _transaction != null && _transaction.IsActive; + public bool IsInActiveTransaction - { - get - { - if (transaction != null && transaction.IsActive) - return true; - return Factory.TransactionFactory.IsInDistributedActiveTransaction(session); - } - } + => IsInActiveExplicitTransaction || Factory.TransactionFactory.IsInActiveSystemTransaction(Session); public bool IsConnected - { - get { return connection != null || ownConnection; } - } + => _connection != null || _ownConnection; + + public bool ShouldAutoJoinTransaction { get; } public void Reconnect() { if (IsConnected) { - throw new HibernateException("session already connected"); + throw new HibernateException("Session already connected"); } - ownConnection = true; + _ownConnection = true; } public void Reconnect(DbConnection suppliedConnection) { if (IsConnected) { - throw new HibernateException("session already connected"); + throw new HibernateException("Session already connected"); } - log.Debug("reconnecting session"); - connection = suppliedConnection; - ownConnection = false; + _log.Debug("Reconnecting session"); + _connection = suppliedConnection; + _ownConnection = false; + + // May fail if the supplied connection is enlisted in another transaction, which would be an user + // error. (Either disable auto join transaction or supply an enlist-able connection.) + if (_currentSystemTransaction != null) + _connection.EnlistTransaction(_currentSystemTransaction); } public DbConnection Close() { - if (batcher != null) - { - batcher.Dispose(); - } + _batcher?.Dispose(); + + _transaction?.Dispose(); - if (transaction != null) + if (_backupConnection != null) { - transaction.Dispose(); + _log.Warn("Backup connection was still defined at time of closing."); + Factory.ConnectionProvider.CloseConnection(_backupConnection); + _backupConnection = null; } // When the connection is null nothing needs to be done - if there // is a value for connection then Disconnect() was not called - so we // need to ensure it gets called. - if (connection == null) + if (_connection == null) { - ownConnection = false; + _ownConnection = false; return null; } - else - { - return Disconnect(); - } + return Disconnect(); } private DbConnection DisconnectSuppliedConnection() { - if (connection == null) + if (_connection == null) { throw new HibernateException("Session already disconnected"); } - var c = connection; - connection = null; + var c = _connection; + _connection = null; return c; } private void DisconnectOwnConnection() { - if (connection == null) + if (_connection == null) { // No active connection return; } - if (batcher != null) - { - batcher.CloseCommands(); - } + _batcher?.CloseCommands(); CloseConnection(); } public DbConnection Disconnect() { - if (IsInActiveTransaction) - throw new InvalidOperationException("Disconnect cannot be called while a transaction is in progress."); + if (IsInActiveExplicitTransaction) + throw new InvalidOperationException("Disconnect cannot be called while an explicit transaction is in progress."); - if (!ownConnection) + if (!_ownConnection) { return DisconnectSuppliedConnection(); } - else - { - DisconnectOwnConnection(); - ownConnection = false; - return null; - } + + DisconnectOwnConnection(); + _ownConnection = false; + return null; } private void CloseConnection() { - Factory.ConnectionProvider.CloseConnection(connection); - connection = null; + Factory.ConnectionProvider.CloseConnection(_connection); + _connection = null; } public DbConnection GetConnection() { - if (connection == null) + if (!_allowConnectionUsage) + { + throw new HibernateException("Connection usage is currently disallowed"); + } + + if (_connectionReleaseRequired) { - if (ownConnection) + _connectionReleaseRequired = false; + if (_connection != null) { - connection = Factory.ConnectionProvider.GetConnection(); + _log.Debug("Releasing database connection"); + CloseConnection(); + } + } + + if (_connectionEnlistmentRequired) + { + _connectionEnlistmentRequired = false; + // No null check on transaction: we need to do it for connection supporting it, and + // _connectionEnlistmentRequired should not be set if the transaction is null while the + // connection does not support it. + _connection?.EnlistTransaction(_currentSystemTransaction); + } + + if (_connection == null) + { + if (_ownConnection) + { + _connection = Factory.ConnectionProvider.GetConnection(); + // Will fail if the connection is already enlisted in another transaction. + // Probable case: nested transaction scope with connection auto-enlistment enabled. + // That is an user error. + if (_currentSystemTransaction != null) + _connection.EnlistTransaction(_currentSystemTransaction); + if (Factory.Statistics.IsStatisticsEnabled) { Factory.StatisticsImplementor.Connect(); } } - else if (session.IsOpen) + else if (Session.IsOpen) { throw new HibernateException("Session is currently disconnected"); } @@ -220,7 +260,7 @@ public DbConnection GetConnection() throw new HibernateException("Session is closed"); } } - return connection; + return _connection; } public void AfterTransaction() @@ -229,19 +269,19 @@ public void AfterTransaction() { AggressiveRelease(); } - else if (IsAggressiveRelease && batcher.HasOpenResources) + else if (IsAggressiveRelease && _batcher.HasOpenResources) { - log.Info("forcing batcher resource cleanup on transaction completion; forgot to close ScrollableResults/Enumerable?"); - batcher.CloseCommands(); + _log.Info("Forcing batcher resource cleanup on transaction completion; forgot to close ScrollableResults/Enumerable?"); + _batcher.CloseCommands(); AggressiveRelease(); } else if (IsOnCloseRelease) { - // log a message about potential connection leaks - log.Debug( - "transaction completed on session with on_close connection release mode; be sure to close the session to release ADO.Net resources!"); + // _log a message about potential connection leaks + _log.Debug( + "Transaction completed on session with on_close connection release mode; be sure to close the session to release ADO.Net resources!"); } - transaction = null; + _transaction = null; } public void AfterStatement() @@ -250,16 +290,16 @@ public void AfterStatement() { if (!_releasesEnabled) { - log.Debug("skipping aggressive-release due to manual disabling"); + _log.Debug("Skipping aggressive-release due to manual disabling"); } - else if (batcher.HasOpenResources) + else if (_batcher.HasOpenResources) { - log.Debug("skipping aggressive-release due to open resources on batcher"); + _log.Debug("Skipping aggressive-release due to open resources on batcher"); } // TODO H3: //else if (borrowedConnection != null) //{ - // log.Debug("skipping aggressive-release due to borrowed connection"); + // _log.Debug("skipping aggressive-release due to borrowed connection"); //} else { @@ -270,12 +310,17 @@ public void AfterStatement() private void AggressiveRelease() { - if (ownConnection && flushingFromDtcTransaction == false) + if (_ownConnection) { - log.Debug("aggressively releasing database connection"); - if (connection != null) + if (_connection != null) { - CloseConnection(); + if (_processingFromSystemTransaction) + _connectionReleaseRequired = true; + else + { + _log.Debug("Aggressively releasing database connection"); + CloseConnection(); + } } } } @@ -287,7 +332,7 @@ public void FlushBeginning() { if (_flushDepth == 0) { - log.Debug("registering flush begin"); + _log.Debug("Registering flush begin"); _releasesEnabled = false; } _flushDepth++; @@ -303,7 +348,7 @@ public void FlushEnding() if (_flushDepth == 0) { _releasesEnabled = true; - log.Debug("registering flush end"); + _log.Debug("Registering flush end"); } AfterStatement(); } @@ -312,20 +357,20 @@ public void FlushEnding() private ConnectionManager(SerializationInfo info, StreamingContext context) { - ownConnection = info.GetBoolean("ownConnection"); - session = (ISessionImplementor)info.GetValue("session", typeof(ISessionImplementor)); - connectionReleaseMode = + _ownConnection = info.GetBoolean("ownConnection"); + Session = (ISessionImplementor)info.GetValue("session", typeof(ISessionImplementor)); + _connectionReleaseMode = (ConnectionReleaseMode)info.GetValue("connectionReleaseMode", typeof(ConnectionReleaseMode)); - interceptor = (IInterceptor)info.GetValue("interceptor", typeof(IInterceptor)); + _interceptor = (IInterceptor)info.GetValue("interceptor", typeof(IInterceptor)); } [SecurityCritical] public void GetObjectData(SerializationInfo info, StreamingContext context) { - info.AddValue("ownConnection", ownConnection); - info.AddValue("session", session, typeof(ISessionImplementor)); - info.AddValue("connectionReleaseMode", connectionReleaseMode, typeof(ConnectionReleaseMode)); - info.AddValue("interceptor", interceptor, typeof(IInterceptor)); + info.AddValue("ownConnection", _ownConnection); + info.AddValue("session", Session, typeof(ISessionImplementor)); + info.AddValue("connectionReleaseMode", _connectionReleaseMode, typeof(ConnectionReleaseMode)); + info.AddValue("interceptor", _interceptor, typeof(IInterceptor)); } #endregion @@ -334,7 +379,7 @@ public void GetObjectData(SerializationInfo info, StreamingContext context) void IDeserializationCallback.OnDeserialization(object sender) { - batcher = Factory.Settings.BatcherFactory.CreateBatcher(this, interceptor); + _batcher = Factory.Settings.BatcherFactory.CreateBatcher(this, _interceptor); } #endregion @@ -342,109 +387,152 @@ void IDeserializationCallback.OnDeserialization(object sender) public ITransaction BeginTransaction(IsolationLevel isolationLevel) { Transaction.Begin(isolationLevel); - return transaction; + return _transaction; } public ITransaction BeginTransaction() { Transaction.Begin(); - return transaction; + return _transaction; } public ITransaction Transaction { get { - if (transaction == null) + if (_transaction == null) { - transaction = Factory.TransactionFactory.CreateTransaction(session); + _transaction = Factory.TransactionFactory.CreateTransaction(Session); } - return transaction; + return _transaction; } } public void AfterNonTransactionalQuery(bool success) { - log.Debug("after autocommit"); + _log.Debug("After autocommit"); } private bool IsAfterTransactionRelease - { - get { return connectionReleaseMode == ConnectionReleaseMode.AfterTransaction; } - } + => _connectionReleaseMode == ConnectionReleaseMode.AfterTransaction; private bool IsOnCloseRelease - { - get { return connectionReleaseMode == ConnectionReleaseMode.OnClose; } - } + => _connectionReleaseMode == ConnectionReleaseMode.OnClose; private bool IsAggressiveRelease - { - get - { - if (connectionReleaseMode == ConnectionReleaseMode.AfterTransaction) - { - return !IsInActiveTransaction; - } - return false; - } - } + => _connectionReleaseMode == ConnectionReleaseMode.AfterTransaction && !IsInActiveTransaction; public ISessionFactoryImplementor Factory - { - get { return session.Factory; } - } + => Session.Factory; public bool IsReadyForSerialization { get { - if (ownConnection) - { - return connection == null && !batcher.HasOpenResources; - } - else + if (_ownConnection) { - return connection == null; + return _connection == null && !_batcher.HasOpenResources; } + return _connection == null; } } /// The batcher managed by this ConnectionManager. public IBatcher Batcher + => _batcher; + + public DbCommand CreateCommand() { - get { return batcher; } + var result = GetConnection().CreateCommand(); + Transaction.Enlist(result); + return result; } - public IDisposable FlushingFromDtcTransaction + /// + /// Enlist the connection into provided transaction if the connection should be enlisted. + /// Do nothing in case an explicit transaction is ongoing. + /// + /// The transaction in which the connection should be enlisted. + public void EnlistIfRequired(System.Transactions.Transaction transaction) { - get + if (transaction == _currentSystemTransaction) + return; + + _currentSystemTransaction = transaction; + + // Most connections do not support enlisting in a system transaction while already participating + // in a local transaction. They are not supposed to be mixed anyway. + if (IsInActiveExplicitTransaction && transaction != null) + throw new InvalidOperationException("Cannot enlist in a system transaction while an explicit transaction has been started on the session."); + + if (_connection == null || _connectionReleaseRequired) + return; + + // Some drivers do not support enlistment with null. Skip for them, they are supposed + // to un-enlist by themselves. + if (transaction == null && !Factory.ConnectionProvider.Driver.SupportsNullEnlistment) + { + return; + } + + if (!_allowConnectionUsage) + { + _connectionEnlistmentRequired = true; + return; + } + + _connection.EnlistTransaction(transaction); + } + + public IDisposable BeginProcessingFromSystemTransaction(bool allowConnectionUsage) + { + var needSwapping = _ownConnection && allowConnectionUsage && + Factory.Dialect.SupportsConcurrentWritingConnectionsInSameTransaction; + if (needSwapping) { - flushingFromDtcTransaction = true; - return new StopFlushingFromDtcTransaction(this); + if (Batcher.HasOpenResources) + throw new InvalidOperationException("Batcher still has opened ressources at time of processing from system transaction."); + // Swap out current connection for avoiding using it concurrently to its own 2PC + _backupConnection = _connection; + _backupCurrentSystemTransaction = _currentSystemTransaction; + _connection = null; + _currentSystemTransaction = null; } + _processingFromSystemTransaction = true; + var wasAllowingConnectionUsage = _allowConnectionUsage; + _allowConnectionUsage = allowConnectionUsage; + return new EndFlushingFromSystemTransaction(this, needSwapping, wasAllowingConnectionUsage); } - private class StopFlushingFromDtcTransaction : IDisposable + private class EndFlushingFromSystemTransaction : IDisposable { - private readonly ConnectionManager manager; + private readonly ConnectionManager _manager; + private readonly bool _hasSwappedConnection; + private readonly bool _wasAllowingConnectionUsage; - public StopFlushingFromDtcTransaction(ConnectionManager manager) + public EndFlushingFromSystemTransaction(ConnectionManager manager, bool hasSwappedConnection, bool wasAllowingConnectionUsage) { - this.manager = manager; + _manager = manager; + _hasSwappedConnection = hasSwappedConnection; + _wasAllowingConnectionUsage = wasAllowingConnectionUsage; } public void Dispose() { - manager.flushingFromDtcTransaction = false; + _manager._processingFromSystemTransaction = false; + _manager._allowConnectionUsage = _wasAllowingConnectionUsage; + + if (!_hasSwappedConnection) + return; + + // Release the connection potentially acquired for processing from system transaction. + _manager.DisconnectOwnConnection(); + // Swap back current connection + _manager._connection = _manager._backupConnection; + _manager._currentSystemTransaction = _manager._backupCurrentSystemTransaction; + _manager._backupConnection = null; + _manager._backupCurrentSystemTransaction = null; } } - - public DbCommand CreateCommand() - { - var result = GetConnection().CreateCommand(); - Transaction.Enlist(result); - return result; - } } } diff --git a/src/NHibernate/Async/AdoNet/ConnectionManager.cs b/src/NHibernate/Async/AdoNet/ConnectionManager.cs index 34ad9019ff2..1e4c1aa4655 100644 --- a/src/NHibernate/Async/AdoNet/ConnectionManager.cs +++ b/src/NHibernate/Async/AdoNet/ConnectionManager.cs @@ -27,29 +27,66 @@ namespace NHibernate.AdoNet public partial class ConnectionManager : ISerializable, IDeserializationCallback { - public async Task GetConnectionAsync(CancellationToken cancellationToken) + public Task GetConnectionAsync(CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - if (connection == null) + if (!_allowConnectionUsage) + { + throw new HibernateException("Connection usage is currently disallowed"); + } + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return InternalGetConnectionAsync(); + async Task InternalGetConnectionAsync() { - if (ownConnection) + + if (_connectionReleaseRequired) { - connection = await (Factory.ConnectionProvider.GetConnectionAsync(cancellationToken)).ConfigureAwait(false); - if (Factory.Statistics.IsStatisticsEnabled) + _connectionReleaseRequired = false; + if (_connection != null) { - Factory.StatisticsImplementor.Connect(); + _log.Debug("Releasing database connection"); + CloseConnection(); } } - else if (session.IsOpen) + + if (_connectionEnlistmentRequired) { - throw new HibernateException("Session is currently disconnected"); + _connectionEnlistmentRequired = false; + // No null check on transaction: we need to do it for connection supporting it, and + // _connectionEnlistmentRequired should not be set if the transaction is null while the + // connection does not support it. + _connection?.EnlistTransaction(_currentSystemTransaction); } - else + + if (_connection == null) { - throw new HibernateException("Session is closed"); + if (_ownConnection) + { + _connection = await (Factory.ConnectionProvider.GetConnectionAsync(cancellationToken)).ConfigureAwait(false); + // Will fail if the connection is already enlisted in another transaction. + // Probable case: nested transaction scope with connection auto-enlistment enabled. + // That is an user error. + if (_currentSystemTransaction != null) + _connection.EnlistTransaction(_currentSystemTransaction); + + if (Factory.Statistics.IsStatisticsEnabled) + { + Factory.StatisticsImplementor.Connect(); + } + } + else if (Session.IsOpen) + { + throw new HibernateException("Session is currently disconnected"); + } + else + { + throw new HibernateException("Session is closed"); + } } + return _connection; } - return connection; } public async Task CreateCommandAsync(CancellationToken cancellationToken) diff --git a/src/NHibernate/Async/Dialect/Dialect.cs b/src/NHibernate/Async/Dialect/Dialect.cs index 30d7e37ac60..16ad0614856 100644 --- a/src/NHibernate/Async/Dialect/Dialect.cs +++ b/src/NHibernate/Async/Dialect/Dialect.cs @@ -14,6 +14,7 @@ using System.Data; using System.Data.Common; using System.Text; +using System.Transactions; using NHibernate.Dialect.Function; using NHibernate.Dialect.Lock; using NHibernate.Dialect.Schema; diff --git a/src/NHibernate/Async/Impl/SessionImpl.cs b/src/NHibernate/Async/Impl/SessionImpl.cs index 441e6352993..0155eb0b253 100644 --- a/src/NHibernate/Async/Impl/SessionImpl.cs +++ b/src/NHibernate/Async/Impl/SessionImpl.cs @@ -73,6 +73,12 @@ public override async Task AfterTransactionCompletionAsync(bool success, ITransa log.Error("exception in interceptor afterTransactionCompletion()", t); } + if (IsClosed) + { + // Cleanup was delayed to transaction completion, do it now. + persistenceContext.Clear(); + } + //if (autoClear) // Clear(); } @@ -1207,7 +1213,12 @@ public override async Task BeforeTransactionCompletionAsync(ITransaction tx, Can using (new SessionIdLoggingContext(SessionId)) { log.Debug("before transaction completion"); - await (FlushBeforeTransactionCompletionAsync(cancellationToken)).ConfigureAwait(false); + var context = TransactionContext; + if (tx == null && context == null) + throw new InvalidOperationException("Cannot complete a transaction without neither an explicit transaction nor an ambient one."); + // Always allow flushing from explicit transactions, otherwise check if flushing from scope is enabled. + if (tx != null || context.CanFlushOnSystemTransactionCompleted) + await (FlushBeforeTransactionCompletionAsync(cancellationToken)).ConfigureAwait(false); actionQueue.BeforeTransactionCompletion(); try { diff --git a/src/NHibernate/Async/Impl/StatelessSessionImpl.cs b/src/NHibernate/Async/Impl/StatelessSessionImpl.cs index 499bbd90ce1..3862392cb28 100644 --- a/src/NHibernate/Async/Impl/StatelessSessionImpl.cs +++ b/src/NHibernate/Async/Impl/StatelessSessionImpl.cs @@ -202,7 +202,20 @@ public override Task BeforeTransactionCompletionAsync(ITransaction tx, Cancellat { return Task.FromCanceled(cancellationToken); } - return FlushBeforeTransactionCompletionAsync(cancellationToken); + try + { + var context = TransactionContext; + if (tx == null && context == null) + return Task.FromException(new InvalidOperationException("Cannot complete a transaction without neither an explicit transaction nor an ambient one.")); + // Always allow flushing from explicit transactions, otherwise check if flushing from scope is enabled. + if (tx != null || context.CanFlushOnSystemTransactionCompleted) + return FlushBeforeTransactionCompletionAsync(cancellationToken); + return Task.CompletedTask; + } + catch (Exception ex) + { + return Task.FromException(ex); + } } public override async Task FlushBeforeTransactionCompletionAsync(CancellationToken cancellationToken) diff --git a/src/NHibernate/Async/Transaction/AdoNetTransactionFactory.cs b/src/NHibernate/Async/Transaction/AdoNetTransactionFactory.cs index 2b8d102c784..197a9065580 100644 --- a/src/NHibernate/Async/Transaction/AdoNetTransactionFactory.cs +++ b/src/NHibernate/Async/Transaction/AdoNetTransactionFactory.cs @@ -9,10 +9,9 @@ using System; -using System.Collections; +using System.Collections.Generic; using System.Data; using System.Data.Common; - using NHibernate.Dialect; using NHibernate.Engine; using NHibernate.Engine.Transaction; @@ -29,88 +28,111 @@ namespace NHibernate.Transaction public partial class AdoNetTransactionFactory : ITransactionFactory { - public async Task ExecuteWorkInIsolationAsync(ISessionImplementor session, IIsolatedWork work, bool transacted, CancellationToken cancellationToken) + /// + public virtual Task ExecuteWorkInIsolationAsync(ISessionImplementor session, IIsolatedWork work, bool transacted, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - DbConnection connection = null; - DbTransaction trans = null; - // bool wasAutoCommit = false; - try + if (session == null) + throw new ArgumentNullException(nameof(session)); + if (work == null) + throw new ArgumentNullException(nameof(work)); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return InternalExecuteWorkInIsolationAsync(); + async Task InternalExecuteWorkInIsolationAsync() { - // We make an exception for SQLite and use the session's connection, - // since SQLite only allows one connection to the database. - if (session.Factory.Dialect is SQLiteDialect) - connection = session.Connection; - else - connection = await (session.Factory.ConnectionProvider.GetConnectionAsync(cancellationToken)).ConfigureAwait(false); - if (transacted) + DbConnection connection = null; + DbTransaction trans = null; + // bool wasAutoCommit = false; + try { - trans = connection.BeginTransaction(); - // TODO NH: a way to read the autocommit state is needed - //if (TransactionManager.GetAutoCommit(connection)) - //{ - // wasAutoCommit = true; - // TransactionManager.SetAutoCommit(connection, false); - //} - } + // We make an exception for SQLite and use the session's connection, + // since SQLite only allows one connection to the database. + if (session.Factory.Dialect is SQLiteDialect) + connection = session.Connection; + else + connection = await (session.Factory.ConnectionProvider.GetConnectionAsync(cancellationToken)).ConfigureAwait(false); - await (work.DoWorkAsync(connection, trans, cancellationToken)).ConfigureAwait(false); + if (transacted) + { + trans = connection.BeginTransaction(); + // TODO NH: a way to read the autocommit state is needed + //if (TransactionManager.GetAutoCommit(connection)) + //{ + // wasAutoCommit = true; + // TransactionManager.SetAutoCommit(connection, false); + //} + } - if (transacted) - { - trans.Commit(); - //TransactionManager.Commit(connection); + await (work.DoWorkAsync(connection, trans, cancellationToken)).ConfigureAwait(false); + + if (transacted) + { + trans.Commit(); + //TransactionManager.Commit(connection); + } } - } - catch (Exception t) - { - using (new SessionIdLoggingContext(session.SessionId)) + catch (Exception t) { - try + using (new SessionIdLoggingContext(session.SessionId)) { - if (trans != null && connection.State != ConnectionState.Closed) + try { - trans.Rollback(); + if (trans != null && connection.State != ConnectionState.Closed) + { + trans.Rollback(); + } + } + catch (Exception ignore) + { + isolaterLog.Debug("Unable to rollback transaction", ignore); } - } - catch (Exception ignore) - { - isolaterLog.Debug("unable to release connection on exception [" + ignore + "]"); - } - if (t is HibernateException) - { - throw; + if (t is HibernateException) + { + throw; + } + else if (t is DbException) + { + throw ADOExceptionHelper.Convert(session.Factory.SQLExceptionConverter, t, + "error performing isolated work"); + } + else + { + throw new HibernateException("error performing isolated work", t); + } } - else if (t is DbException) + } + finally + { + //if (transacted && wasAutoCommit) + //{ + // try + // { + // // TODO NH: reset autocommit + // // TransactionManager.SetAutoCommit(connection, true); + // } + // catch (Exception) + // { + // log.Debug("was unable to reset connection back to auto-commit"); + // } + //} + + try { - throw ADOExceptionHelper.Convert(session.Factory.SQLExceptionConverter, t, - "error performing isolated work"); + trans?.Dispose(); } - else + catch (Exception ignore) { - throw new HibernateException("error performing isolated work", t); + isolaterLog.Warn("Unable to dispose transaction", ignore); } + + if (session.Factory.Dialect is SQLiteDialect == false) + session.Factory.ConnectionProvider.CloseConnection(connection); } } - finally - { - //if (transacted && wasAutoCommit) - //{ - // try - // { - // // TODO NH: reset autocommit - // // TransactionManager.SetAutoCommit(connection, true); - // } - // catch (Exception) - // { - // log.Debug("was unable to reset connection back to auto-commit"); - // } - //} - if (session.Factory.Dialect is SQLiteDialect == false) - session.Factory.ConnectionProvider.CloseConnection(connection); - } } } } \ No newline at end of file diff --git a/src/NHibernate/Async/Transaction/AdoNetWithDistributedTransactionFactory.cs b/src/NHibernate/Async/Transaction/AdoNetWithSystemTransactionFactory.cs similarity index 61% rename from src/NHibernate/Async/Transaction/AdoNetWithDistributedTransactionFactory.cs rename to src/NHibernate/Async/Transaction/AdoNetWithSystemTransactionFactory.cs index c79dd9180c3..5491a41407a 100644 --- a/src/NHibernate/Async/Transaction/AdoNetWithDistributedTransactionFactory.cs +++ b/src/NHibernate/Async/Transaction/AdoNetWithSystemTransactionFactory.cs @@ -9,31 +9,32 @@ using System; -using System.Collections; +using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Transactions; +using NHibernate.AdoNet; using NHibernate.Engine; using NHibernate.Engine.Transaction; using NHibernate.Impl; +using NHibernate.Util; namespace NHibernate.Transaction { using System.Threading.Tasks; - using System.Threading; /// /// Contains generated async methods /// - public partial class AdoNetWithDistributedTransactionFactory : ITransactionFactory + public partial class AdoNetWithSystemTransactionFactory : AdoNetTransactionFactory { - public async Task ExecuteWorkInIsolationAsync(ISessionImplementor session, IIsolatedWork work, bool transacted, CancellationToken cancellationToken) + /// + public override async Task ExecuteWorkInIsolationAsync(ISessionImplementor session, IIsolatedWork work, bool transacted, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); using (var tx = new TransactionScope(TransactionScopeOption.Suppress, TransactionScopeAsyncFlowOption.Enabled)) { - // instead of duplicating the logic, we suppress the DTC transaction and create - // our own transaction instead - await (adoNetTransactionFactory.ExecuteWorkInIsolationAsync(session, work, transacted, cancellationToken)).ConfigureAwait(false); + await (base.ExecuteWorkInIsolationAsync(session, work, transacted, cancellationToken)).ConfigureAwait(false); tx.Complete(); } } diff --git a/src/NHibernate/Async/Transaction/ITransactionFactory.cs b/src/NHibernate/Async/Transaction/ITransactionFactory.cs index f5d980d0bc3..9b46459077d 100644 --- a/src/NHibernate/Async/Transaction/ITransactionFactory.cs +++ b/src/NHibernate/Async/Transaction/ITransactionFactory.cs @@ -8,10 +8,8 @@ //------------------------------------------------------------------------------ -using System.Collections; -using System.Transactions; -using NHibernate; -using NHibernate.AdoNet; +using System; +using System.Collections.Generic; using NHibernate.Engine; using NHibernate.Engine.Transaction; @@ -25,6 +23,14 @@ namespace NHibernate.Transaction public partial interface ITransactionFactory { + /// + /// Execute a work outside of the current transaction (if any). + /// + /// The session for which an isolated work has to be executed. + /// The work to execute. + /// for encapsulating the work in a dedicated + /// transaction, for not transacting it. + /// A cancellation token that can be used to cancel the work Task ExecuteWorkInIsolationAsync(ISessionImplementor session, IIsolatedWork work, bool transacted, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/src/NHibernate/Cfg/Environment.cs b/src/NHibernate/Cfg/Environment.cs index 1368e26d3bb..74dc349b056 100644 --- a/src/NHibernate/Cfg/Environment.cs +++ b/src/NHibernate/Cfg/Environment.cs @@ -5,6 +5,7 @@ using NHibernate.Bytecode; using NHibernate.Cfg.ConfigurationSchema; +using NHibernate.Engine; using NHibernate.Util; namespace NHibernate.Cfg @@ -118,6 +119,26 @@ public static string Version public const string OutputStylesheet = "xml.output_stylesheet"; public const string TransactionStrategy = "transaction.factory_class"; + /// + /// Timeout duration in milliseconds for the system transaction completion lock. + /// When a system transaction completes, it may have its completion events running on concurrent threads, + /// after scope disposal. This occurs when the transaction is distributed. + /// This notably concerns . + /// NHibernate protects the session from being concurrently used by the code following the scope disposal + /// with a lock. To prevent any application freeze, this lock has a default timeout of five seconds. If the + /// application appears to require longer (!) running transaction completion events, this setting allows to + /// raise this timeout. -1 disables the timeout. + /// + public const string SystemTransactionCompletionLockTimeout = "transaction.system_completion_lock_timeout"; + /// + /// When a system transaction is being prepared, is using connection during this process enabled? + /// Default is , for supporting with transaction factories + /// supporting system transactions. But this requires enlisting additional connections, retaining disposed + /// sessions and their connections till transaction end, and may trigger undesired transaction promotions to + /// distributed. Set to for disabling using connections from system + /// transaction preparation, while still benefiting from on querying. + /// + public const string UseConnectionOnSystemTransactionPrepare = "transaction.use_connection_on_system_prepare"; // Unused, not implemented (and somewhat Java-specific) public const string TransactionManagerStrategy = "transaction.manager_lookup_class"; diff --git a/src/NHibernate/Cfg/SettingsFactory.cs b/src/NHibernate/Cfg/SettingsFactory.cs index be76a5c5e9e..51230758cf5 100644 --- a/src/NHibernate/Cfg/SettingsFactory.cs +++ b/src/NHibernate/Cfg/SettingsFactory.cs @@ -393,14 +393,16 @@ private static System.Type CreateLinqQueryProviderType(IDictionary properties) { string className = PropertiesHelper.GetString( - Environment.TransactionStrategy, properties, typeof(AdoNetWithDistributedTransactionFactory).FullName); + Environment.TransactionStrategy, properties, typeof(AdoNetWithSystemTransactionFactory).FullName); log.Info("Transaction factory: " + className); try { - return + var transactionFactory = (ITransactionFactory) Environment.BytecodeProvider.ObjectsFactory.CreateInstance(ReflectHelper.ClassForName(className)); + transactionFactory.Configure(properties); + return transactionFactory; } catch (Exception cnfe) { diff --git a/src/NHibernate/Dialect/Dialect.cs b/src/NHibernate/Dialect/Dialect.cs index f45efa8bd11..c1f0407edc1 100644 --- a/src/NHibernate/Dialect/Dialect.cs +++ b/src/NHibernate/Dialect/Dialect.cs @@ -4,6 +4,7 @@ using System.Data; using System.Data.Common; using System.Text; +using System.Transactions; using NHibernate.Dialect.Function; using NHibernate.Dialect.Lock; using NHibernate.Dialect.Schema; @@ -1400,6 +1401,16 @@ public IList GetTokens() } } + /// + /// Does this dialect support concurrent writing connections? + /// + public virtual bool SupportsConcurrentWritingConnections => true; + + /// + /// Does this dialect support concurrent writing connections in the same transaction? + /// + public virtual bool SupportsConcurrentWritingConnectionsInSameTransaction => SupportsConcurrentWritingConnections; + #endregion #region Limit/offset support @@ -2093,6 +2104,15 @@ public virtual bool SupportsSubSelects /// public virtual bool SupportsHavingOnGroupedByComputation => true; + /// + /// Does this dialect support distributed transaction? + /// + /// + /// Distributed transactions usually imply the use of, but using + /// TransactionScope does not imply the transaction will be distributed. + /// + public virtual bool SupportsDistributedTransactions => true; + #endregion /// diff --git a/src/NHibernate/Dialect/FirebirdDialect.cs b/src/NHibernate/Dialect/FirebirdDialect.cs index 673c07c729e..5cd56a4d08a 100644 --- a/src/NHibernate/Dialect/FirebirdDialect.cs +++ b/src/NHibernate/Dialect/FirebirdDialect.cs @@ -129,7 +129,7 @@ public override SqlString GetLimitString(SqlString queryString, SqlString offset return queryString.Insert(insertIndex, limitFragment.ToSqlString()); } - #region Temporaray Table Support + #region Temporary Table Support public override bool SupportsTemporaryTables { @@ -548,5 +548,18 @@ private static bool IsUnallowedDecimal(DbType dbType, int precision) { return dbType == DbType.Decimal && precision > MAX_DECIMAL_PRECISION; } + + #region Informational metadata + + /// + /// Does this dialect support distributed transaction? + /// + /// + /// As of v2.5 and 3.0.2, fails rollback-ing changes when distributed: changes are instead persisted in database. + /// (With ADO .Net Provider 5.9.1) + /// + public override bool SupportsDistributedTransactions => false; + + #endregion } } \ No newline at end of file diff --git a/src/NHibernate/Dialect/MsSqlCeDialect.cs b/src/NHibernate/Dialect/MsSqlCeDialect.cs index e3b0da091e0..4b39f2b87de 100644 --- a/src/NHibernate/Dialect/MsSqlCeDialect.cs +++ b/src/NHibernate/Dialect/MsSqlCeDialect.cs @@ -329,6 +329,11 @@ private static int GetAfterSelectInsertPoint(SqlString sql) throw new NotSupportedException("The query should start with 'SELECT' or 'SELECT DISTINCT'"); } + /// + /// Does this dialect support concurrent writing connections in the same transaction? + /// + public override bool SupportsConcurrentWritingConnectionsInSameTransaction => false; + public override long TimestampResolutionInTicks { get @@ -348,6 +353,15 @@ public override long TimestampResolutionInTicks /// public override bool SupportsScalarSubSelects => false; + /// + /// Does this dialect support distributed transaction? + /// + /// + /// Fails enlisting a connection into a distributed transaction, fails promoting a transaction + /// to distributed when it has already a connection enlisted. + /// + public override bool SupportsDistributedTransactions => false; + #endregion } } diff --git a/src/NHibernate/Dialect/MySQLDialect.cs b/src/NHibernate/Dialect/MySQLDialect.cs index bf37f805485..da1954f6601 100644 --- a/src/NHibernate/Dialect/MySQLDialect.cs +++ b/src/NHibernate/Dialect/MySQLDialect.cs @@ -513,6 +513,15 @@ public override long TimestampResolutionInTicks } } + /// + /// Does this dialect support concurrent writing connections in the same transaction? + /// + /// + /// NotSupportedException : Multiple simultaneous connections or connections with different + /// connection strings inside the same transaction are not currently supported. + /// + public override bool SupportsConcurrentWritingConnectionsInSameTransaction => false; + #region Overridden informational metadata public override bool SupportsEmptyInList => false; @@ -532,6 +541,15 @@ public override long TimestampResolutionInTicks /// public override bool SupportsHavingOnGroupedByComputation => false; + /// + /// Does this dialect support distributed transaction? + /// + /// + /// Fails enlisting a connection into a distributed transaction, fails promoting a transaction + /// to distributed when it has already a connection enlisted. + /// + public override bool SupportsDistributedTransactions => false; + #endregion } } \ No newline at end of file diff --git a/src/NHibernate/Dialect/SQLiteDialect.cs b/src/NHibernate/Dialect/SQLiteDialect.cs index da47b16ceee..e7dbc859aee 100644 --- a/src/NHibernate/Dialect/SQLiteDialect.cs +++ b/src/NHibernate/Dialect/SQLiteDialect.cs @@ -387,6 +387,14 @@ public override bool SupportsForeignKeyConstraintInAlterTable get { return false; } } + /// + /// Does this dialect support concurrent writing connections? + /// + /// + /// As documented at https://www.sqlite.org/faq.html#q5 + /// + public override bool SupportsConcurrentWritingConnections => false; + [Serializable] protected class SQLiteCastFunction : CastFunction { diff --git a/src/NHibernate/Driver/DriverBase.cs b/src/NHibernate/Driver/DriverBase.cs index 938a83e3dff..750662d3f1a 100644 --- a/src/NHibernate/Driver/DriverBase.cs +++ b/src/NHibernate/Driver/DriverBase.cs @@ -312,5 +312,14 @@ public DbParameter GenerateOutputParameter(DbCommand command) } public virtual bool RequiresTimeSpanForTime => false; + + public virtual bool SupportsSystemTransactions => true; + + public virtual bool SupportsNullEnlistment => true; + + /// + public virtual bool SupportsEnlistmentWhenAutoEnlistmentIsDisabled => true; + + public virtual bool HasDelayedDistributedTransactionCompletion => false; } } \ No newline at end of file diff --git a/src/NHibernate/Driver/FirebirdClientDriver.cs b/src/NHibernate/Driver/FirebirdClientDriver.cs index 0bfc7407631..32fcb94853e 100644 --- a/src/NHibernate/Driver/FirebirdClientDriver.cs +++ b/src/NHibernate/Driver/FirebirdClientDriver.cs @@ -144,5 +144,40 @@ public void ClearPool(string connectionString) _clearAllPools.Invoke(null, new object[0]); } + + /// + /// This driver support of is not compliant and too heavily + /// restricts what can be done for NHibernate tests. See DNET-764, DNET-766 (and bonus, DNET-765). + /// + /// + /// + /// + /// DNET-764 + /// When auto-enlistment is enabled (Enlist=true in connection string), the driver throws if + /// attempting to open a connection without an ambient transaction. http://tracker.firebirdsql.org/browse/DNET-764 + /// + /// + /// + /// DNET-765 + /// When the connection string does not specify auto-enlistment parameter Enlist, the driver + /// defaults to false. http://tracker.firebirdsql.org/browse/DNET-765 + /// + /// + /// + /// DNET-766 + /// When auto-enlistment is disabled (Enlist=false in connection string), the driver ignores + /// calls to . They silently do + /// nothing, the Firebird connection does not get enlisted. http://tracker.firebirdsql.org/browse/DNET-766 + /// + /// + /// + /// + public override bool SupportsSystemTransactions => false; + + /// + /// . Enlistment is completely disabled when auto-enlistment is disabled. + /// See http://tracker.firebirdsql.org/browse/DNET-766. + /// + public override bool SupportsEnlistmentWhenAutoEnlistmentIsDisabled => false; } } diff --git a/src/NHibernate/Driver/IDriver.cs b/src/NHibernate/Driver/IDriver.cs index 2916037d1f5..4a09d5a5fbf 100644 --- a/src/NHibernate/Driver/IDriver.cs +++ b/src/NHibernate/Driver/IDriver.cs @@ -124,5 +124,39 @@ public interface IDriver /// Does this driver mandates values for time? /// bool RequiresTimeSpanForTime { get; } + + /// + /// Does this driver support ? + /// + bool SupportsSystemTransactions { get; } + + /// + /// Does this driver connections support enlisting with a transaction? + /// + /// Enlisting with allows to leave a completed transaction and + /// starts accepting auto-committed statements. + bool SupportsNullEnlistment { get; } + + /// + /// Does this driver connections support explicitly enlisting with a transaction when auto-enlistment + /// is disabled? + /// + bool SupportsEnlistmentWhenAutoEnlistmentIsDisabled { get; } + + /// + /// Does sometimes this driver finish distributed transaction after end of scope disposal? + /// + /// + /// See https://github.com/npgsql/npgsql/issues/1571#issuecomment-308651461 discussion with a Microsoft + /// employee: MSDTC considers a transaction to be committed once it has collected all participant votes + /// for committing from prepare phase. It then immediately notifies all participants of the outcome. + /// This causes TransactionScope.Dispose to leave while the second phase of participants may still + /// be executing. This means the transaction from the db view point can still be pending and not yet + /// committed after the scope disposal. This is by design of MSDTC and we have to cope with that. + /// Some data provider may have a global locking mechanism causing any subsequent use to wait for the + /// end of the commit phase, but this is not a general case. Some other, as Npgsql < v3.2.5, may + /// crash due to this, because they re-use the connection in the second phase. + /// + bool HasDelayedDistributedTransactionCompletion { get; } } } \ No newline at end of file diff --git a/src/NHibernate/Driver/NpgsqlDriver.cs b/src/NHibernate/Driver/NpgsqlDriver.cs index a18849261a7..1f0701c30ad 100644 --- a/src/NHibernate/Driver/NpgsqlDriver.cs +++ b/src/NHibernate/Driver/NpgsqlDriver.cs @@ -66,6 +66,8 @@ protected override bool SupportsPreparingCommands get { return true; } } + public override bool SupportsNullEnlistment => false; + public override IResultSetsCommand GetResultSetsCommand(Engine.ISessionImplementor session) { return new BasicResultSetsCommand(session); @@ -88,5 +90,7 @@ protected override void InitializeParameter(DbParameter dbParam, string name, Sq // Prior to v3, Npgsql was expecting DateTime for time. // https://github.com/npgsql/npgsql/issues/347 public override bool RequiresTimeSpanForTime => (DriverVersion?.Major ?? 3) >= 3; + + public override bool HasDelayedDistributedTransactionCompletion => true; } } diff --git a/src/NHibernate/Driver/OdbcDriver.cs b/src/NHibernate/Driver/OdbcDriver.cs index c9fca49412e..eb875fe0eda 100644 --- a/src/NHibernate/Driver/OdbcDriver.cs +++ b/src/NHibernate/Driver/OdbcDriver.cs @@ -84,5 +84,10 @@ protected override void InitializeParameter(DbParameter dbParam, string name, Sq } public override bool RequiresTimeSpanForTime => true; + + /// + /// Depends on target DB in the Odbc case. This in facts depends on both the driver and the database. + /// + public override bool HasDelayedDistributedTransactionCompletion => true; } } diff --git a/src/NHibernate/Driver/OracleManagedDataClientDriver.cs b/src/NHibernate/Driver/OracleManagedDataClientDriver.cs index 86cac7dffac..039c42dad81 100644 --- a/src/NHibernate/Driver/OracleManagedDataClientDriver.cs +++ b/src/NHibernate/Driver/OracleManagedDataClientDriver.cs @@ -130,5 +130,7 @@ System.Type IEmbeddedBatcherFactoryProvider.BatcherFactoryClass { get { return typeof (OracleDataClientBatchingBatcherFactory); } } + + public override bool HasDelayedDistributedTransactionCompletion => true; } } diff --git a/src/NHibernate/Driver/SQLite20Driver.cs b/src/NHibernate/Driver/SQLite20Driver.cs index 664d23d41d6..9daf88a5a5a 100644 --- a/src/NHibernate/Driver/SQLite20Driver.cs +++ b/src/NHibernate/Driver/SQLite20Driver.cs @@ -5,19 +5,20 @@ namespace NHibernate.Driver { /// - /// NHibernate driver for the System.Data.SQLite data provider for .NET 2.0. + /// NHibernate driver for the System.Data.SQLite data provider for .NET. /// /// - ///

+ /// /// In order to use this driver you must have the System.Data.SQLite.dll assembly available /// for NHibernate to load. This assembly includes the SQLite.dll or SQLite3.dll libraries. - ///

- ///

- /// You can get the System.Data.SQLite.dll assembly from http://sourceforge.net/projects/sqlite-dotnet2. - ///

- ///

- /// Please check http://www.sqlite.org/ for more information regarding SQLite. - ///

+ /// + /// + /// You can get the System.Data.SQLite.dll assembly from + /// https://system.data.sqlite.org/ + /// + /// + /// Please check https://www.sqlite.org/ for more information regarding SQLite. + /// ///
public partial class SQLite20Driver : ReflectionBasedDriver { @@ -86,5 +87,9 @@ public override bool SupportsMultipleQueries { get { return true; } } + + public override bool SupportsNullEnlistment => false; + + public override bool HasDelayedDistributedTransactionCompletion => true; } } \ No newline at end of file diff --git a/src/NHibernate/Driver/SqlClientDriver.cs b/src/NHibernate/Driver/SqlClientDriver.cs index fadec4ea5ab..945723a1ac0 100644 --- a/src/NHibernate/Driver/SqlClientDriver.cs +++ b/src/NHibernate/Driver/SqlClientDriver.cs @@ -201,5 +201,11 @@ public override bool SupportsMultipleQueries { get { return true; } } + + /// + /// With read committed snapshot or lower, SQL Server may have not actually already committed the transaction + /// right after the scope disposal. + /// + public override bool HasDelayedDistributedTransactionCompletion => true; } } diff --git a/src/NHibernate/Driver/SqlServerCeDriver.cs b/src/NHibernate/Driver/SqlServerCeDriver.cs index bc06f7f9aae..188e2bb229b 100644 --- a/src/NHibernate/Driver/SqlServerCeDriver.cs +++ b/src/NHibernate/Driver/SqlServerCeDriver.cs @@ -136,5 +136,14 @@ private void AdjustDbParamTypeForLargeObjects(DbParameter dbParam, SqlType sqlTy dbParamSqlDbTypeProperty.SetValue(dbParam, SqlDbType.NText, null); } } + + public override bool SupportsNullEnlistment => false; + + /// + /// . Enlistment is completely disabled when auto-enlistment is disabled. + /// does nothing in + /// this case. + /// + public override bool SupportsEnlistmentWhenAutoEnlistmentIsDisabled => false; } } \ No newline at end of file diff --git a/src/NHibernate/Engine/ISessionImplementor.cs b/src/NHibernate/Engine/ISessionImplementor.cs index 01d117c01a7..86c2e5945eb 100644 --- a/src/NHibernate/Engine/ISessionImplementor.cs +++ b/src/NHibernate/Engine/ISessionImplementor.cs @@ -264,20 +264,23 @@ public partial interface ISessionImplementor IQuery GetNamedQuery(string queryName); - /// Determine whether the session is closed. Provided separately from - /// {@link #isOpen()} as this method does not attempt any JTA sync - /// registration, where as {@link #isOpen()} does; which makes this one - /// nicer to use for most internal purposes. + /// + /// Determine whether the session is closed. Provided separately from + /// IsOpen as this method does not attempt any system transaction sync + /// registration, whereas IsOpen is allowed to (does not currently, but may do + /// in a future version as it is the case in Hibernate); which makes this one + /// nicer to use for most internal purposes. /// - /// True if the session is closed; false otherwise. + /// + /// if the session is closed; otherwise. /// bool IsClosed { get; } void Flush(); - /// - /// Does this Session have an active Hibernate transaction - /// or is there a JTA transaction in progress? + /// + /// Does this ISession have an active NHibernate transaction + /// or is there a system transaction in progress in which the session is enlisted? /// bool TransactionInProgress { get; } @@ -295,7 +298,24 @@ public partial interface ISessionImplementor ITransactionContext TransactionContext { get; set; } - void CloseSessionFromDistributedTransaction(); + /// + /// Join the system transaction. + /// + /// + /// + /// Sessions auto-join current transaction by default on their first usage within a scope. + /// This can be disabled with from + /// a session builder obtained with . + /// + /// + /// This method allows to explicitly join the current transaction. It does nothing if it is already + /// joined. + /// + /// + /// Thrown if there is no current transaction. + void JoinTransaction(); + + void CloseSessionFromSystemTransaction(); EntityKey GenerateEntityKey(object id, IEntityPersister persister); diff --git a/src/NHibernate/ISession.cs b/src/NHibernate/ISession.cs index 61d9afee770..e0e52320259 100644 --- a/src/NHibernate/ISession.cs +++ b/src/NHibernate/ISession.cs @@ -707,6 +707,23 @@ public partial interface ISession : IDisposable /// ITransaction Transaction { get; } + /// + /// Join the system transaction. + /// + /// + /// + /// Sessions auto-join current transaction by default on their first usage within a scope. + /// This can be disabled with from + /// a session builder obtained with . + /// + /// + /// This method allows to explicitly join the current transaction. It does nothing if it is already + /// joined. + /// + /// + /// Thrown if there is no current transaction. + void JoinTransaction(); + /// /// Creates a new Criteria for the entity class. /// diff --git a/src/NHibernate/ISessionBuilder.cs b/src/NHibernate/ISessionBuilder.cs index e2bbfc45d64..8fa6112914d 100644 --- a/src/NHibernate/ISessionBuilder.cs +++ b/src/NHibernate/ISessionBuilder.cs @@ -65,6 +65,16 @@ public interface ISessionBuilder where T : ISessionBuilder /// , for method chaining. T AutoClose(bool autoClose); + /// + /// Should the session be automatically enlisted in ambient system transaction? + /// Enabled by default. Disabling it does not prevent connections having auto-enlistment + /// enabled to get enlisted in current ambient transaction when opened. + /// + /// Should the session be automatically explicitly + /// enlisted in ambient transaction. + /// , for method chaining. + T AutoJoinTransaction(bool autoJoinTransaction); + /// /// Specify the initial FlushMode to use for the opened Session. /// diff --git a/src/NHibernate/ISharedSessionBuilder.cs b/src/NHibernate/ISharedSessionBuilder.cs index 964cf9464a7..aaec69b9120 100644 --- a/src/NHibernate/ISharedSessionBuilder.cs +++ b/src/NHibernate/ISharedSessionBuilder.cs @@ -11,6 +11,8 @@ public interface ISharedSessionBuilder : ISessionBuilder /// Signifies that the connection from the original session should be used to create the new session. /// The original session remains responsible for it and its closing will cause sharing sessions to be no /// more usable. + /// Causes specified ConnectionReleaseMode and AutoJoinTransaction to be ignored and + /// replaced by those of the original session. /// /// , for method chaining. ISharedSessionBuilder Connection(); @@ -34,9 +36,15 @@ public interface ISharedSessionBuilder : ISessionBuilder ISharedSessionBuilder FlushMode(); /// - /// Signifies that the autoClose flag from the original session should be used to create the new session. + /// Signifies that the AutoClose flag from the original session should be used to create the new session. /// /// , for method chaining. ISharedSessionBuilder AutoClose(); + + /// + /// Signifies that the AutoJoinTransaction flag from the original session should be used to create the new session. + /// + /// , for method chaining. + ISharedSessionBuilder AutoJoinTransaction(); } } \ No newline at end of file diff --git a/src/NHibernate/IStatelessSession.cs b/src/NHibernate/IStatelessSession.cs index ff6eb2531b1..55c697c89c5 100644 --- a/src/NHibernate/IStatelessSession.cs +++ b/src/NHibernate/IStatelessSession.cs @@ -249,6 +249,23 @@ public partial interface IStatelessSession : IDisposable /// A NHibernate transaction ITransaction BeginTransaction(IsolationLevel isolationLevel); + /// + /// Join the system transaction. + /// + /// + /// + /// Sessions auto-join current transaction by default on their first usage within a scope. + /// This can be disabled with from + /// a session builder obtained with . + /// + /// + /// This method allows to explicitly join the current transaction. It does nothing if it is already + /// joined. + /// + /// + /// Thrown if there is no current transaction. + void JoinTransaction(); + /// /// Sets the batch size of the session /// diff --git a/src/NHibernate/IStatelessSessionBuilder.cs b/src/NHibernate/IStatelessSessionBuilder.cs index 25cbca41692..77641ad9e4e 100644 --- a/src/NHibernate/IStatelessSessionBuilder.cs +++ b/src/NHibernate/IStatelessSessionBuilder.cs @@ -29,6 +29,16 @@ public interface IStatelessSessionBuilder /// IStatelessSessionBuilder Connection(DbConnection connection); + /// + /// Should the session be automatically enlisted in ambient system transaction? + /// Enabled by default. Disabling it does not prevent connections having auto-enlistment + /// enabled to get enlisted in current ambient transaction when opened. + /// + /// Should the session be automatically explicitly + /// enlisted in ambient transaction. + /// , for method chaining. + IStatelessSessionBuilder AutoJoinTransaction(bool autoJoinTransaction); + // NH remark: seems a bit overkill for now. On Hibernate side, they have at least another option: the tenant. } } \ No newline at end of file diff --git a/src/NHibernate/Impl/AbstractSessionImpl.cs b/src/NHibernate/Impl/AbstractSessionImpl.cs index b0a70666a23..ce4c8c2c56d 100644 --- a/src/NHibernate/Impl/AbstractSessionImpl.cs +++ b/src/NHibernate/Impl/AbstractSessionImpl.cs @@ -81,7 +81,7 @@ public ISessionFactoryImplementor Factory } public abstract IBatcher Batcher { get; } - public abstract void CloseSessionFromDistributedTransaction(); + public abstract void CloseSessionFromSystemTransaction(); public virtual IList List(IQueryExpression queryExpression, QueryParameters parameters) { @@ -271,6 +271,11 @@ public bool IsClosed protected internal virtual void CheckAndUpdateSessionStatus() { ErrorIfClosed(); + + // Ensure the session does not run on a thread supposed to be blocked, waiting + // for transaction completion. + TransactionContext?.Wait(); + EnlistInAmbientTransactionIfNeeded(); } @@ -296,15 +301,6 @@ protected bool IsAlreadyDisposed protected internal void SetClosed() { - try - { - if (TransactionContext != null) - TransactionContext.Dispose(); - } - catch (Exception) - { - //ignore - } closed = true; } @@ -408,7 +404,13 @@ protected void AfterOperation(bool success) protected void EnlistInAmbientTransactionIfNeeded() { - _factory.TransactionFactory.EnlistInDistributedTransactionIfNeeded(this); + _factory.TransactionFactory.EnlistInSystemTransactionIfNeeded(this); + } + + public void JoinTransaction() + { + CheckAndUpdateSessionStatus(); + _factory.TransactionFactory.ExplicitJoinSystemTransaction(this); } internal IOuterJoinLoadable GetOuterJoinLoadable(string entityName) diff --git a/src/NHibernate/Impl/ISessionCreationOptions.cs b/src/NHibernate/Impl/ISessionCreationOptions.cs index 88ea471aa67..0e11afa2352 100644 --- a/src/NHibernate/Impl/ISessionCreationOptions.cs +++ b/src/NHibernate/Impl/ISessionCreationOptions.cs @@ -14,6 +14,8 @@ public interface ISessionCreationOptions bool ShouldAutoClose { get; } + bool ShouldAutoJoinTransaction { get; } + DbConnection UserSuppliedConnection { get; } IInterceptor SessionInterceptor { get; } diff --git a/src/NHibernate/Impl/SessionFactoryImpl.cs b/src/NHibernate/Impl/SessionFactoryImpl.cs index 4ce86214e86..a729d88c557 100644 --- a/src/NHibernate/Impl/SessionFactoryImpl.cs +++ b/src/NHibernate/Impl/SessionFactoryImpl.cs @@ -1291,6 +1291,7 @@ internal class SessionBuilderImpl : ISessionBuilder, ISessionCreationOptio private ConnectionReleaseMode _connectionReleaseMode; private FlushMode _flushMode; private bool _autoClose; + private bool _autoJoinTransaction = true; public SessionBuilderImpl(SessionFactoryImpl sessionFactory) { @@ -1315,6 +1316,8 @@ protected void SetSelf(T self) public virtual bool ShouldAutoClose => _autoClose; + public virtual bool ShouldAutoJoinTransaction => _autoJoinTransaction; + public DbConnection UserSuppliedConnection => _connection; // NH different implementation: Hibernate here ignore EmptyInterceptor.Instance too, resulting @@ -1372,6 +1375,12 @@ public virtual T AutoClose(bool autoClose) return _this; } + public virtual T AutoJoinTransaction(bool autoJoinTransaction) + { + _autoJoinTransaction = autoJoinTransaction; + return _this; + } + public virtual T FlushMode(FlushMode flushMode) { _flushMode = flushMode; @@ -1384,7 +1393,6 @@ public virtual T FlushMode(FlushMode flushMode) internal class StatelessSessionBuilderImpl : IStatelessSessionBuilder, ISessionCreationOptions { private readonly SessionFactoryImpl _sessionFactory; - private DbConnection _connection; public StatelessSessionBuilderImpl(SessionFactoryImpl sessionFactory) { @@ -1395,7 +1403,13 @@ public StatelessSessionBuilderImpl(SessionFactoryImpl sessionFactory) public IStatelessSessionBuilder Connection(DbConnection connection) { - _connection = connection; + UserSuppliedConnection = connection; + return this; + } + + public IStatelessSessionBuilder AutoJoinTransaction(bool autoJoinTransaction) + { + ShouldAutoJoinTransaction = autoJoinTransaction; return this; } @@ -1403,7 +1417,9 @@ public IStatelessSessionBuilder Connection(DbConnection connection) public bool ShouldAutoClose => false; - public DbConnection UserSuppliedConnection => _connection; + public bool ShouldAutoJoinTransaction { get; private set; } = true; + + public DbConnection UserSuppliedConnection { get; private set; } public IInterceptor SessionInterceptor => EmptyInterceptor.Instance; diff --git a/src/NHibernate/Impl/SessionImpl.cs b/src/NHibernate/Impl/SessionImpl.cs index 2511e356cdf..486701f7cb1 100644 --- a/src/NHibernate/Impl/SessionImpl.cs +++ b/src/NHibernate/Impl/SessionImpl.cs @@ -205,7 +205,8 @@ internal SessionImpl(SessionFactoryImpl factory, ISessionCreationOptions options } else { - connectionManager = new ConnectionManager(this, options.UserSuppliedConnection, connectionReleaseMode, Interceptor); + connectionManager = new ConnectionManager( + this, options.UserSuppliedConnection, connectionReleaseMode, Interceptor, options.ShouldAutoJoinTransaction); } if (factory.Statistics.IsStatisticsEnabled) @@ -338,6 +339,12 @@ public override void AfterTransactionCompletion(bool success, ITransaction tx) log.Error("exception in interceptor afterTransactionCompletion()", t); } + if (IsClosed) + { + // Cleanup was delayed to transaction completion, do it now. + persistenceContext.Clear(); + } + //if (autoClear) // Clear(); } @@ -347,6 +354,9 @@ private void Cleanup() { using (new SessionIdLoggingContext(SessionId)) { + // Let the after tran clear that if we are still in an active system transaction. + if (TransactionContext?.IsInActiveTransaction == true) + return; persistenceContext.Clear(); } } @@ -521,7 +531,7 @@ IList Find(string query, object[] values, IType[] types) } } - public override void CloseSessionFromDistributedTransaction() + public override void CloseSessionFromSystemTransaction() { Dispose(true); } @@ -1576,10 +1586,18 @@ public void Dispose() { using (new SessionIdLoggingContext(SessionId)) { - log.Debug(string.Format("[session-id={0}] running ISession.Dispose()", SessionId)); - if (TransactionContext != null) + log.DebugFormat("[session-id={0}] running ISession.Dispose()", SessionId); + // Ensure we are not disposing concurrently to transaction completion, which would + // remove the context. (Do not store it into a local variable before the Wait.) + TransactionContext?.Wait(); + // If the synchronization above is bugged and lets a race condition remaining, we may + // blow here with a null ref exception after the null check. We could introduce + // a local variable for avoiding it, but that would turn a failure causing an exception + // into a failure causing a session and connection leak. So do not do it, better blow away + // with a null ref rather than silently leaking a session. And then fix the synchronization. + if (TransactionContext != null && TransactionContext.CanFlushOnSystemTransactionCompleted) { - TransactionContext.ShouldCloseSessionOnDistributedTransactionCompleted = true; + TransactionContext.ShouldCloseSessionOnSystemTransactionCompleted = true; return; } Dispose(true); @@ -2123,7 +2141,12 @@ public override void BeforeTransactionCompletion(ITransaction tx) using (new SessionIdLoggingContext(SessionId)) { log.Debug("before transaction completion"); - FlushBeforeTransactionCompletion(); + var context = TransactionContext; + if (tx == null && context == null) + throw new InvalidOperationException("Cannot complete a transaction without neither an explicit transaction nor an ambient one."); + // Always allow flushing from explicit transactions, otherwise check if flushing from scope is enabled. + if (tx != null || context.CanFlushOnSystemTransactionCompleted) + FlushBeforeTransactionCompletion(); actionQueue.BeforeTransactionCompletion(); try { @@ -2569,6 +2592,8 @@ public virtual ISharedSessionBuilder Connection() public virtual ISharedSessionBuilder AutoClose() => AutoClose(_session.autoCloseSessionEnabled); + public virtual ISharedSessionBuilder AutoJoinTransaction() => AutoJoinTransaction(_session.ConnectionManager.ShouldAutoJoinTransaction); + // NH different implementation, avoid an error case. public override ISharedSessionBuilder Connection(DbConnection connection) { diff --git a/src/NHibernate/Impl/StatelessSessionImpl.cs b/src/NHibernate/Impl/StatelessSessionImpl.cs index f2f37fa5181..32c55b71928 100644 --- a/src/NHibernate/Impl/StatelessSessionImpl.cs +++ b/src/NHibernate/Impl/StatelessSessionImpl.cs @@ -41,7 +41,7 @@ internal StatelessSessionImpl(SessionFactoryImpl factory, ISessionCreationOption { temporaryPersistenceContext = new StatefulPersistenceContext(this); connectionManager = new ConnectionManager(this, options.UserSuppliedConnection, ConnectionReleaseMode.AfterTransaction, - EmptyInterceptor.Instance); + EmptyInterceptor.Instance, options.ShouldAutoJoinTransaction); if (log.IsDebugEnabled) { @@ -105,7 +105,7 @@ public override IBatcher Batcher } } - public override void CloseSessionFromDistributedTransaction() + public override void CloseSessionFromSystemTransaction() { Dispose(true); } @@ -218,7 +218,12 @@ public override void AfterTransactionBegin(ITransaction tx) public override void BeforeTransactionCompletion(ITransaction tx) { - FlushBeforeTransactionCompletion(); + var context = TransactionContext; + if (tx == null && context == null) + throw new InvalidOperationException("Cannot complete a transaction without neither an explicit transaction nor an ambient one."); + // Always allow flushing from explicit transactions, otherwise check if flushing from scope is enabled. + if (tx != null || context.CanFlushOnSystemTransactionCompleted) + FlushBeforeTransactionCompletion(); } public override void FlushBeforeTransactionCompletion() @@ -861,9 +866,17 @@ public void Dispose() using (new SessionIdLoggingContext(SessionId)) { log.Debug("running IStatelessSession.Dispose()"); - if (TransactionContext != null) - { - TransactionContext.ShouldCloseSessionOnDistributedTransactionCompleted = true; + // Ensure we are not disposing concurrently to transaction completion, which would + // remove the context. (Do not store it into a local variable before the Wait.) + TransactionContext?.Wait(); + // If the synchronization above is bugged and lets a race condition remaining, we may + // blow here with a null ref exception after the null check. We could introduce + // a local variable for avoiding it, but that would turn a failure causing an exception + // into a failure causing a session and connection leak. So do not do it, better blow away + // with a null ref rather than silently leaking a session. And then fix the synchronization. + if (TransactionContext != null && TransactionContext.CanFlushOnSystemTransactionCompleted) + { + TransactionContext.ShouldCloseSessionOnSystemTransactionCompleted = true; return; } Dispose(true); diff --git a/src/NHibernate/Transaction/AdoNetTransactionFactory.cs b/src/NHibernate/Transaction/AdoNetTransactionFactory.cs index b542a9556dd..57515c6e6d9 100644 --- a/src/NHibernate/Transaction/AdoNetTransactionFactory.cs +++ b/src/NHibernate/Transaction/AdoNetTransactionFactory.cs @@ -1,8 +1,7 @@ using System; -using System.Collections; +using System.Collections.Generic; using System.Data; using System.Data.Common; - using NHibernate.Dialect; using NHibernate.Engine; using NHibernate.Engine.Transaction; @@ -11,27 +10,46 @@ namespace NHibernate.Transaction { + /// + /// Minimal factory implementation. + /// Does not support system . + /// public partial class AdoNetTransactionFactory : ITransactionFactory { private readonly IInternalLogger isolaterLog = LoggerProvider.LoggerFor(typeof(ITransactionFactory)); - public ITransaction CreateTransaction(ISessionImplementor session) + /// + public virtual ITransaction CreateTransaction(ISessionImplementor session) { return new AdoTransaction(session); } - public void EnlistInDistributedTransactionIfNeeded(ISessionImplementor session) + /// + public virtual void EnlistInSystemTransactionIfNeeded(ISessionImplementor session) { // nothing need to do here, we only support local transactions with this factory } - public bool IsInDistributedActiveTransaction(ISessionImplementor session) + /// + public virtual void ExplicitJoinSystemTransaction(ISessionImplementor session) + { + throw new NotSupportedException("The current transaction factory does not support system transactions."); + } + + /// + public virtual bool IsInActiveSystemTransaction(ISessionImplementor session) { return false; } - public void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork work, bool transacted) + /// + public virtual void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork work, bool transacted) { + if (session == null) + throw new ArgumentNullException(nameof(session)); + if (work == null) + throw new ArgumentNullException(nameof(work)); + DbConnection connection = null; DbTransaction trans = null; // bool wasAutoCommit = false; @@ -76,7 +94,7 @@ public void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork wo } catch (Exception ignore) { - isolaterLog.Debug("unable to release connection on exception [" + ignore + "]"); + isolaterLog.Debug("Unable to rollback transaction", ignore); } if (t is HibernateException) @@ -86,7 +104,7 @@ public void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork wo else if (t is DbException) { throw ADOExceptionHelper.Convert(session.Factory.SQLExceptionConverter, t, - "error performing isolated work"); + "error performing isolated work"); } else { @@ -108,12 +126,23 @@ public void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork wo // log.Debug("was unable to reset connection back to auto-commit"); // } //} + + try + { + trans?.Dispose(); + } + catch (Exception ignore) + { + isolaterLog.Warn("Unable to dispose transaction", ignore); + } + if (session.Factory.Dialect is SQLiteDialect == false) session.Factory.ConnectionProvider.CloseConnection(connection); } } - public void Configure(IDictionary props) + /// + public virtual void Configure(IDictionary props) { } } diff --git a/src/NHibernate/Transaction/AdoNetWithDistributedTransactionFactory.cs b/src/NHibernate/Transaction/AdoNetWithDistributedTransactionFactory.cs deleted file mode 100644 index 9b611091a3f..00000000000 --- a/src/NHibernate/Transaction/AdoNetWithDistributedTransactionFactory.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; -using System.Collections; -using System.Linq; -using System.Transactions; -using NHibernate.Engine; -using NHibernate.Engine.Transaction; -using NHibernate.Impl; - -namespace NHibernate.Transaction -{ - public partial class AdoNetWithDistributedTransactionFactory : ITransactionFactory - { - private static readonly IInternalLogger logger = LoggerProvider.LoggerFor(typeof(ITransactionFactory)); - - private readonly AdoNetTransactionFactory adoNetTransactionFactory = new AdoNetTransactionFactory(); - - public void Configure(IDictionary props) - { - } - - public ITransaction CreateTransaction(ISessionImplementor session) - { - return new AdoTransaction(session); - } - - public void EnlistInDistributedTransactionIfNeeded(ISessionImplementor session) - { - if (session.TransactionContext != null) - return; - - if (System.Transactions.Transaction.Current == null) - return; - - var originatingSession = session.ConnectionManager.Session; - if (originatingSession != session) - { - session.TransactionContext = new DependentContext(); - } - - if (originatingSession.TransactionContext != null) - return; - - session = originatingSession; - - var transactionContext = new DistributedTransactionContext(session, - System.Transactions.Transaction.Current); - session.TransactionContext = transactionContext; - logger.DebugFormat("enlisted into DTC transaction: {0}", - transactionContext.AmbientTransation.IsolationLevel); - session.AfterTransactionBegin(null); - foreach (var dependentSession in session.ConnectionManager.DependentSessions) - dependentSession.AfterTransactionBegin(null); - - TransactionCompletedEventHandler handler = null; - - handler = delegate(object sender, TransactionEventArgs e) - { - using (new SessionIdLoggingContext(session.SessionId)) - { - ((DistributedTransactionContext) session.TransactionContext).IsInActiveTransaction = false; - - bool wasSuccessful = false; - try - { - wasSuccessful = e.Transaction.TransactionInformation.Status - == TransactionStatus.Committed; - } - catch (ObjectDisposedException ode) - { - logger.Warn("Completed transaction was disposed, assuming transaction rollback", ode); - } - session.ConnectionManager.AfterTransaction(); - session.AfterTransactionCompletion(wasSuccessful, null); - foreach (var dependentSession in session.ConnectionManager.DependentSessions) - dependentSession.AfterTransactionCompletion(wasSuccessful, null); - - Cleanup(session); - } - - e.Transaction.TransactionCompleted -= handler; - }; - - transactionContext.AmbientTransation.TransactionCompleted += handler; - - transactionContext.AmbientTransation.EnlistVolatile(transactionContext, - EnlistmentOptions.EnlistDuringPrepareRequired); - } - - private static void Cleanup(ISessionImplementor session) - { - foreach (var dependentSession in session.ConnectionManager.DependentSessions.ToList()) - { - if (dependentSession.TransactionContext?.ShouldCloseSessionOnDistributedTransactionCompleted ?? false) - // This change the enumerated collection. - dependentSession.CloseSessionFromDistributedTransaction(); - dependentSession.TransactionContext?.Dispose(); - dependentSession.TransactionContext = null; - } - if (session.TransactionContext.ShouldCloseSessionOnDistributedTransactionCompleted) - { - session.CloseSessionFromDistributedTransaction(); - } - session.TransactionContext.Dispose(); - session.TransactionContext = null; - } - - public bool IsInDistributedActiveTransaction(ISessionImplementor session) - { - var distributedTransactionContext = (DistributedTransactionContext)session.ConnectionManager.Session.TransactionContext; - return distributedTransactionContext != null && - distributedTransactionContext.IsInActiveTransaction; - } - - public void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork work, bool transacted) - { - using (var tx = new TransactionScope(TransactionScopeOption.Suppress)) - { - // instead of duplicating the logic, we suppress the DTC transaction and create - // our own transaction instead - adoNetTransactionFactory.ExecuteWorkInIsolation(session, work, transacted); - tx.Complete(); - } - } - - public class DistributedTransactionContext : ITransactionContext, IEnlistmentNotification - { - public System.Transactions.Transaction AmbientTransation { get; set; } - public bool ShouldCloseSessionOnDistributedTransactionCompleted { get; set; } - private readonly ISessionImplementor sessionImplementor; - public bool IsInActiveTransaction; - - public DistributedTransactionContext(ISessionImplementor sessionImplementor, System.Transactions.Transaction transaction) - { - this.sessionImplementor = sessionImplementor; - AmbientTransation = transaction.Clone(); - IsInActiveTransaction = true; - } - - #region IEnlistmentNotification Members - - void IEnlistmentNotification.Prepare(PreparingEnlistment preparingEnlistment) - { - using (new SessionIdLoggingContext(sessionImplementor.SessionId)) - { - try - { - using (var tx = new TransactionScope(AmbientTransation)) - { - if (sessionImplementor.ConnectionManager.IsConnected) - { - using (sessionImplementor.ConnectionManager.FlushingFromDtcTransaction) - { - sessionImplementor.BeforeTransactionCompletion(null); - foreach (var dependentSession in sessionImplementor.ConnectionManager.DependentSessions) - dependentSession.BeforeTransactionCompletion(null); - - logger.Debug("prepared for DTC transaction"); - - tx.Complete(); - } - } - } - preparingEnlistment.Prepared(); - } - catch (Exception exception) - { - logger.Error("DTC transaction prepare phase failed", exception); - preparingEnlistment.ForceRollback(exception); - } - } - } - - void IEnlistmentNotification.Commit(Enlistment enlistment) - { - using (new SessionIdLoggingContext(sessionImplementor.SessionId)) - { - logger.Debug("committing DTC transaction"); - // we have nothing to do here, since it is the actual - // DB connection that will commit the transaction - enlistment.Done(); - IsInActiveTransaction = false; - } - } - - void IEnlistmentNotification.Rollback(Enlistment enlistment) - { - using (new SessionIdLoggingContext(sessionImplementor.SessionId)) - { - logger.Debug("rolled back DTC transaction"); - // Currently AfterTransactionCompletion is called by the handler for the TransactionCompleted event. - //sessionImplementor.AfterTransactionCompletion(false, null); - enlistment.Done(); - IsInActiveTransaction = false; - } - } - - void IEnlistmentNotification.InDoubt(Enlistment enlistment) - { - using (new SessionIdLoggingContext(sessionImplementor.SessionId)) - { - sessionImplementor.ConnectionManager.AfterTransaction(); - sessionImplementor.AfterTransactionCompletion(false, null); - foreach (var dependentSession in sessionImplementor.ConnectionManager.DependentSessions) - dependentSession.AfterTransactionCompletion(false, null); - logger.Debug("DTC transaction is in doubt"); - enlistment.Done(); - IsInActiveTransaction = false; - } - } - - #endregion - - public void Dispose() - { - if (AmbientTransation != null) - AmbientTransation.Dispose(); - } - } - - public class DependentContext : ITransactionContext - { - public bool ShouldCloseSessionOnDistributedTransactionCompleted { get; set; } - - public void Dispose() { } - } - } -} diff --git a/src/NHibernate/Transaction/AdoNetWithSystemTransactionFactory.cs b/src/NHibernate/Transaction/AdoNetWithSystemTransactionFactory.cs new file mode 100644 index 00000000000..65dc8cc3e55 --- /dev/null +++ b/src/NHibernate/Transaction/AdoNetWithSystemTransactionFactory.cs @@ -0,0 +1,583 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Transactions; +using NHibernate.AdoNet; +using NHibernate.Engine; +using NHibernate.Engine.Transaction; +using NHibernate.Impl; +using NHibernate.Util; + +namespace NHibernate.Transaction +{ + /// + /// factory implementation supporting system + /// . + /// + public partial class AdoNetWithSystemTransactionFactory : AdoNetTransactionFactory + { + private static readonly IInternalLogger _logger = LoggerProvider.LoggerFor(typeof(ITransactionFactory)); + + /// + /// See . + /// + protected int SystemTransactionCompletionLockTimeout { get; private set; } + /// + /// See . + /// + protected bool UseConnectionOnSystemTransactionPrepare { get; private set; } + + /// + public override void Configure(IDictionary props) + { + base.Configure(props); + SystemTransactionCompletionLockTimeout = + PropertiesHelper.GetInt32(Cfg.Environment.SystemTransactionCompletionLockTimeout, props, 5000); + if (SystemTransactionCompletionLockTimeout < -1) + throw new HibernateException( + $"Invalid {Cfg.Environment.SystemTransactionCompletionLockTimeout} value: {SystemTransactionCompletionLockTimeout}. It can not be less than -1."); + UseConnectionOnSystemTransactionPrepare = + PropertiesHelper.GetBoolean(Cfg.Environment.UseConnectionOnSystemTransactionPrepare, props, true); + } + + /// + public override void EnlistInSystemTransactionIfNeeded(ISessionImplementor session) + { + if (session == null) + throw new ArgumentNullException(nameof(session)); + + if (!session.ConnectionManager.ShouldAutoJoinTransaction) + { + return; + } + + JoinSystemTransaction(session, System.Transactions.Transaction.Current); + } + + /// + public override void ExplicitJoinSystemTransaction(ISessionImplementor session) + { + if (session == null) + throw new ArgumentNullException(nameof(session)); + var transaction = System.Transactions.Transaction.Current; + if (transaction == null) + throw new HibernateException("No current system transaction to join."); + + JoinSystemTransaction(session, transaction); + } + + /// + /// Enlist the session in the supplied transaction. + /// + /// The session to enlist. + /// The transaction to enlist with. Can be . + protected virtual void JoinSystemTransaction(ISessionImplementor session, System.Transactions.Transaction transaction) + { + // Handle the transaction on the originating session only. + var originatingSession = session.ConnectionManager.Session; + + if (originatingSession.TransactionContext == null || + // Support connection switch when connection auto-enlistment is not enabled + originatingSession.ConnectionManager.ProcessingFromSystemTransaction) + { + originatingSession.ConnectionManager.EnlistIfRequired(transaction); + } + + if (transaction == null) + return; + + if (originatingSession.TransactionContext != null) + { + if (session.TransactionContext == null) + { + // New dependent session + EnlistDependentSession(session, originatingSession.TransactionContext); + } + return; + } + + var transactionContext = CreateAndEnlistMainContext(originatingSession, transaction); + originatingSession.TransactionContext = transactionContext; + + _logger.DebugFormat( + "Enlisted into system transaction: {0}", + transaction.IsolationLevel); + + originatingSession.AfterTransactionBegin(null); + foreach (var dependentSession in originatingSession.ConnectionManager.DependentSessions) + { + EnlistDependentSession(dependentSession, transactionContext); + } + } + + /// + /// Create a transaction context for enlisting a session with a , + /// and enlist the context in the transaction. + /// + /// The session to be enlisted. + /// The transaction into which the context has to be enlisted. + /// The created transaction context. + protected virtual ITransactionContext CreateAndEnlistMainContext( + ISessionImplementor session, + System.Transactions.Transaction transaction) + { + var transactionContext = new SystemTransactionContext( + session, transaction, SystemTransactionCompletionLockTimeout, + UseConnectionOnSystemTransactionPrepare); + transactionContext.EnlistedTransaction.EnlistVolatile( + transactionContext, + UseConnectionOnSystemTransactionPrepare + ? EnlistmentOptions.EnlistDuringPrepareRequired + : EnlistmentOptions.None); + return transactionContext; + } + + private void EnlistDependentSession(ISessionImplementor dependentSession, ITransactionContext mainContext) + { + dependentSession.TransactionContext = CreateDependentContext(dependentSession, mainContext); + dependentSession.AfterTransactionBegin(null); + } + + /// + /// Create a transaction context for a dependent session. + /// + /// The dependent session. + /// The context of the session owning the . + /// A dependent context for the session. + protected virtual ITransactionContext CreateDependentContext(ISessionImplementor dependentSession, ITransactionContext mainContext) + { + return new DependentContext(mainContext); + } + + /// + public override bool IsInActiveSystemTransaction(ISessionImplementor session) + => session?.TransactionContext?.IsInActiveTransaction ?? false; + + /// + public override void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork work, bool transacted) + { + using (var tx = new TransactionScope(TransactionScopeOption.Suppress)) + { + base.ExecuteWorkInIsolation(session, work, transacted); + tx.Complete(); + } + } + + /// + /// Transaction context for enlisting a session with a system . + /// It is meant for being the concrete class enlisted in the transaction. + /// + public class SystemTransactionContext : ITransactionContext, IEnlistmentNotification + { + /// + /// The transaction in which this context is enlisted. + /// + protected internal System.Transactions.Transaction EnlistedTransaction { get; } + /// + public bool ShouldCloseSessionOnSystemTransactionCompleted { get; set; } + /// + public bool IsInActiveTransaction { get; protected set; } = true; + /// + public virtual bool CanFlushOnSystemTransactionCompleted => _useConnectionOnSystemTransactionPrepare; + + private readonly ISessionImplementor _session; + private readonly bool _useConnectionOnSystemTransactionPrepare; + private readonly System.Transactions.Transaction _originalTransaction; + private readonly ManualResetEventSlim _lock = new ManualResetEventSlim(true); + private volatile bool _needCompletionLocking = true; + // Required for not locking the completion phase itself when locking session usages from concurrent threads. + private readonly AsyncLocal _bypassLock = new AsyncLocal(); + private readonly int _systemTransactionCompletionLockTimeout; + + /// + /// Default constructor. + /// + /// The session to enlist with the transaction. + /// The transaction into which the context will be enlisted. + /// See . + /// See . + public SystemTransactionContext( + ISessionImplementor session, + System.Transactions.Transaction transaction, + int systemTransactionCompletionLockTimeout, + bool useConnectionOnSystemTransactionPrepare) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _originalTransaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + EnlistedTransaction = transaction.Clone(); + _systemTransactionCompletionLockTimeout = systemTransactionCompletionLockTimeout; + _useConnectionOnSystemTransactionPrepare = useConnectionOnSystemTransactionPrepare; + } + + /// + public virtual void Wait() + { + if (_isDisposed) + return; + if (_needCompletionLocking && GetTransactionStatus() != TransactionStatus.Active) + { + // Rollback case may end the transaction without a prepare phase, apply the lock. + Lock(); + } + + if (_bypassLock.Value) + return; + try + { + if (_lock.Wait(_systemTransactionCompletionLockTimeout)) + return; + // A call occurring after transaction scope disposal should not have to wait long, since + // the scope disposal is supposed to block until the transaction has completed. When not + // distributed, all is done, no wait. When distributed, with MSDTC, the scope disposal is + // left after all prepare phases, and the complete of all resources including the NHibernate + // one is concurrently raised. So the wait should indeed only have to wait after NHibernate + // AfterTransaction events. + // Remove the block then throw. + Unlock(); + throw new HibernateException( + "Synchronization timeout for transaction completion. Either raise {Cfg.Environment.SystemTransactionCompletionLockTimeout}, or this may be a bug in NHibernate."); + } + catch (HibernateException) + { + throw; + } + catch (Exception ex) + { + _logger.Warn( + "Synchronization failure, assuming it has been concurrently disposed and does not need sync anymore.", + ex); + } + } + + /// + /// Lock the context, causing to block until released. Do nothing if the context + /// has already been locked once. + /// + protected virtual void Lock() + { + if (!_needCompletionLocking || _isDisposed) + return; + _needCompletionLocking = false; + _lock.Reset(); + } + + /// + /// Unlock the context, causing to cease blocking. Do nothing if the context + /// is not locked. + /// + protected virtual void Unlock() + { + _lock.Set(); + } + + /// + /// Safely get the of the context transaction. + /// + /// The of the context transaction, or + /// if it cannot be obtained. + /// The status may no more be obtainable during transaction completion events in case of + /// rollback. + protected TransactionStatus? GetTransactionStatus() + { + try + { + // Cloned transaction is not disposed "unexpectedly", its status is accessible till context disposal. + var status = EnlistedTransaction.TransactionInformation.Status; + if (status != TransactionStatus.Active) + return status; + + // The clone status can be out of date when active, check the original one (which could be disposed if + // the clone is out of date). + return _originalTransaction.TransactionInformation.Status; + } + catch (ObjectDisposedException ode) + { + _logger.Warn("Enlisted transaction status was wrongly active, original transaction being already disposed. Will assume neither active nor committed.", ode); + return null; + } + } + + #region IEnlistmentNotification Members + + /// + /// Prepare the session for the transaction commit. Run + /// for the session and for + /// if any. the context + /// before signaling it is done, or before rollback in case of failure. + /// + /// The object for notifying the prepare phase outcome. + public virtual void Prepare(PreparingEnlistment preparingEnlistment) + { + using (new SessionIdLoggingContext(_session.SessionId)) + { + try + { + using (_session.ConnectionManager.BeginProcessingFromSystemTransaction(_useConnectionOnSystemTransactionPrepare)) + { + if (_useConnectionOnSystemTransactionPrepare) + { + // Ensure any newly acquired connection gets enlisted in the transaction. When distributed, + // this code runs from another thread and we cannot rely on Transaction.Current. + using (var tx = new TransactionScope(EnlistedTransaction)) + { + // Required when both connection auto-enlistment and session auto-enlistment are disabled. + _session.JoinTransaction(); + _session.BeforeTransactionCompletion(null); + foreach (var dependentSession in _session.ConnectionManager.DependentSessions) + dependentSession.BeforeTransactionCompletion(null); + + tx.Complete(); + } + } + else + { + _session.BeforeTransactionCompletion(null); + foreach (var dependentSession in _session.ConnectionManager.DependentSessions) + dependentSession.BeforeTransactionCompletion(null); + } + } + // Lock the session to ensure second phase gets done before the session is used by code following + // the transaction scope disposal. + Lock(); + + _logger.Debug("Prepared for system transaction"); + preparingEnlistment.Prepared(); + } + catch (Exception exception) + { + _logger.Error("System transaction prepare phase failed", exception); + try + { + CompleteTransaction(false); + } + finally + { + preparingEnlistment.ForceRollback(exception); + } + } + } + } + + void IEnlistmentNotification.Commit(Enlistment enlistment) + => ProcessSecondPhase(enlistment, true); + + // May be called in case of scope disposal without being completed, on transaction timeout or other failure like + // deadlocks. Not called in case of ForceRollback from the prepare phase of this enlistment. + void IEnlistmentNotification.Rollback(Enlistment enlistment) + => ProcessSecondPhase(enlistment, false); + + void IEnlistmentNotification.InDoubt(Enlistment enlistment) + => ProcessSecondPhase(enlistment, null); + + /// + /// Handle the second phase callbacks. Has no actual work to do excepted signaling it is done. + /// + /// The enlistment object for signaling to the transaction manager the notification has been handled. + /// if this is a commit callback, if this is a rollback + /// callback, if this is an in-doubt callback. + protected virtual void ProcessSecondPhase(Enlistment enlistment, bool? success) + { + using (new SessionIdLoggingContext(_session.SessionId)) + { + _logger.Debug( + success.HasValue + ? success.Value + ? "Committing system transaction" + : "Rolled back system transaction" + : "System transaction is in doubt"); + + try + { + CompleteTransaction(success ?? false); + } + finally + { + enlistment.Done(); + } + } + } + + #endregion + + /// + /// Handle the transaction completion. Notify of the end of the + /// transaction. Notify end of transaction to the session and to + /// if any. Close sessions requiring it then cleanup transaction contextes and then blocked + /// threads. + /// + /// if the transaction is committed, + /// otherwise. + protected virtual void CompleteTransaction(bool isCommitted) + { + try + { + // Allow transaction completed actions to run while others stay blocked. + _bypassLock.Value = true; + using (new SessionIdLoggingContext(_session.SessionId)) + { + // Flag active as false before running actions, otherwise the session may not cleanup as much + // as possible. + IsInActiveTransaction = false; + // Never allows using connection on after transaction event. And tell the connection manager + // it is called from system transaction. Allows releasing of connection on next usage + // when release mode is on commit, allows un-enlisting the connection on next usage + // when release mode is on close. Without BeginsProcessingFromSystemTransaction(false), + // the connection manager would attempt those operations immediately, causing concurrency + // issues and crashes for some data providers. + using (_session.ConnectionManager.BeginProcessingFromSystemTransaction(false)) + { + _session.ConnectionManager.AfterTransaction(); + // Required for un-enlisting the connection manager when auto-join is false. + // Not done in AfterTransaction, because users may use NHibernate transactions + // within scopes, although mixing is not advised. + if (!ShouldCloseSessionOnSystemTransactionCompleted) + _session.ConnectionManager.EnlistIfRequired(null); + + _session.AfterTransactionCompletion(isCommitted, null); + foreach (var dependentSession in _session.ConnectionManager.DependentSessions) + dependentSession.AfterTransactionCompletion(isCommitted, null); + + Cleanup(_session); + } + } + } + catch (Exception ex) + { + // May be run in a dedicated thread. Log any error, otherwise they could stay unlogged. + _logger.Error("Failure at transaction completion", ex); + throw; + } + finally + { + // Dispose releases blocked threads by the way. + Dispose(); + } + } + + private static void Cleanup(ISessionImplementor session) + { + foreach (var dependentSession in session.ConnectionManager.DependentSessions.ToList()) + { + var dependentContext = dependentSession.TransactionContext; + // Do not nullify TransactionContext here, could create a race condition with + // would be await-er on session for disposal (test cases cleanup checks by example). + if (dependentContext == null) + continue; + // Race condition with session disposal is protected on session side by Wait. + if (dependentContext.ShouldCloseSessionOnSystemTransactionCompleted) + // This changes the enumerated collection. + dependentSession.CloseSessionFromSystemTransaction(); + // Now we can (and even must) nullify it. + dependentSession.TransactionContext = null; + dependentContext.Dispose(); + } + var context = session.TransactionContext; + // Do not nullify TransactionContext here, could create a race condition with + // would be await-er on session for disposal (test cases cleanup checks by example). + // Race condition with session disposal is protected on session side by Wait. + if (context.ShouldCloseSessionOnSystemTransactionCompleted) + { + // This closes the connection manager, which will release the connection. + // This can cause issues with the connection own second phase and concurrency issues + // when the transaction is distributed. In such case, user needs to disable + // UseConnectionOnSystemTransactionPrepare. + session.CloseSessionFromSystemTransaction(); + } + // Now we can (and even must) nullify it. + session.TransactionContext = null; + // No context dispose, done later. + } + + private bool _isDisposed; + + /// + public void Dispose() + { + if (_isDisposed) + // Avoid disposing twice. + return; + _isDisposed = true; + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of the context. + /// + /// if called by . + /// otherwise. Do not access managed resources if it is + /// false. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + Unlock(); + EnlistedTransaction.Dispose(); + _lock.Dispose(); + } + } + } + + /// + /// Transaction context for enlisting a dependent session. Dependent sessions are not owning + /// their . The session owning it will have a transaction context + /// handling all actions for dependent sessions. + /// + public class DependentContext : ITransactionContext + { + /// + public bool IsInActiveTransaction + => MainTransactionContext.IsInActiveTransaction; + + /// + public bool ShouldCloseSessionOnSystemTransactionCompleted { get; set; } + + /// + public virtual bool CanFlushOnSystemTransactionCompleted + => MainTransactionContext.CanFlushOnSystemTransactionCompleted; + + /// + /// The transaction context of the session owning the . + /// + protected ITransactionContext MainTransactionContext { get; } + + /// + /// Default constructor. + /// + /// The transaction context of the session owning the + /// . + public DependentContext(ITransactionContext mainTransactionContext) + { + MainTransactionContext = mainTransactionContext ?? throw new ArgumentNullException(nameof(mainTransactionContext)); + } + + /// + public virtual void Wait() => + MainTransactionContext.Wait(); + + private bool _isDisposed; + + /// + public void Dispose() + { + if (_isDisposed) + // Avoid disposing twice. + return; + _isDisposed = true; + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose of the context. + /// + /// if called by . + /// otherwise. Do not access managed resources if it is + /// false. + protected virtual void Dispose(bool disposing) + { + } + } + } +} diff --git a/src/NHibernate/Transaction/ITransactionContext.cs b/src/NHibernate/Transaction/ITransactionContext.cs index 26d650c7ff1..1a96d3fea8a 100644 --- a/src/NHibernate/Transaction/ITransactionContext.cs +++ b/src/NHibernate/Transaction/ITransactionContext.cs @@ -1,5 +1,6 @@ using System; -using NHibernate.Engine; +using NHibernate.AdoNet; +using NHibernate.Impl; namespace NHibernate.Transaction { @@ -9,6 +10,60 @@ namespace NHibernate.Transaction /// public interface ITransactionContext : IDisposable { - bool ShouldCloseSessionOnDistributedTransactionCompleted { get; set; } + /// + /// Is the transaction still active? + /// + bool IsInActiveTransaction { get; } + /// + /// Should the session be closed upon transaction completion? + /// + bool ShouldCloseSessionOnSystemTransactionCompleted { get; set; } + /// + /// Can the transaction completion trigger a flush? + /// + bool CanFlushOnSystemTransactionCompleted { get; } + /// + /// With some transaction factory, synchronization of session may be required. This method should be called + /// by session before each of its usage where a concurrent transaction completion action could cause a thread + /// safety issue. This method is already called by . + /// + /// + /// + /// This method is required due to MSDTC asynchronism. When a transaction is promoted to distributed, MSDTC + /// starts handling it. See https://github.com/npgsql/npgsql/issues/1571#issuecomment-308651461 for a discussion + /// about it. + /// + /// + /// MSDTC considers the transaction to be committed as soon as it has collected all positive votes from prepare + /// phases of enlisted resources + /// (). + /// It then concurrently lets the disposal leave and allow + /// the code following it to execute, raises transaction completion event + /// () and calls all resources second phase + /// callbacks (). + /// + /// + /// For rollback cases, it depends on what has triggered the rollback. The transaction is marked as aborted. The + /// transaction completion event is raised. If the rollback has been triggered by a resource prepare phase, the + /// rollback callback of that resource will not be called. Prepare phase may not have been called at all for some + /// rollback cases. The called rollback callbacks execute concurrently with transaction completion event and + /// code following the scope disposal. + /// (See (.) + /// + /// + /// In-doubt cases are similar to rollback cases. The transaction completion event is raised too, and run + /// concurrently to in-doubt callbacks + /// () and + /// code following the scope disposal. + /// + /// + /// Due to this, for avoiding concurrency races, this method should block before the last resource signals it is + /// prepared (), and if it detects the transaction + /// is no more active () while not having already + /// blocked. It should be released only once the and + /// transaction completion events and cleanups have been handled. + /// + /// + void Wait(); } } \ No newline at end of file diff --git a/src/NHibernate/Transaction/ITransactionFactory.cs b/src/NHibernate/Transaction/ITransactionFactory.cs index 247c08ad0fb..4d0c473d6ef 100644 --- a/src/NHibernate/Transaction/ITransactionFactory.cs +++ b/src/NHibernate/Transaction/ITransactionFactory.cs @@ -1,37 +1,79 @@ -using System.Collections; -using System.Transactions; -using NHibernate; -using NHibernate.AdoNet; +using System; +using System.Collections.Generic; using NHibernate.Engine; using NHibernate.Engine.Transaction; namespace NHibernate.Transaction { /// - /// An abstract factory for instances. + /// + /// A factory interface for instances. /// Concrete implementations are specified by transaction.factory_class /// configuration property. - /// + /// + /// /// Implementors must be threadsafe and should declare a public default constructor. /// + /// /// public partial interface ITransactionFactory { /// - /// Configure from the given properties + /// Configure from the given properties. /// - /// - void Configure(IDictionary props); + /// The configuration properties. + void Configure(IDictionary props); /// - /// Create a new transaction and return it without starting it. + /// Create a new and return it without starting it. /// + /// The session for which to create a new transaction. + /// The created transaction. ITransaction CreateTransaction(ISessionImplementor session); - void EnlistInDistributedTransactionIfNeeded(ISessionImplementor session); + /// + /// + /// If supporting system , enlist the session in + /// the ambient transaction if any. This method may be call multiple times for the same ambient + /// transaction, and must support it. (Avoid re-enlisting the session if already enlisted.) + /// + /// Do nothing if the transaction factory does not support system transaction, or + /// if the session auto-join transaction option is disabled. + /// + /// The session having to participate in the ambient system transaction if any. + void EnlistInSystemTransactionIfNeeded(ISessionImplementor session); - bool IsInDistributedActiveTransaction(ISessionImplementor session); + /// + /// Enlist the session in the current system . + /// + /// The session to enlist. + /// Thrown if the transaction factory does not support system + /// transactions. + /// Thrown if there is no current transaction. + void ExplicitJoinSystemTransaction(ISessionImplementor session); + /// + /// If supporting system , indicate whether the given + /// is currently enlisted in an system transaction. Otherwise + /// . + /// + /// + /// if the session is enlisted in an system transaction. + /// + /// When a is distributed, a number of processing will run + /// on dedicated threads, and may call this. This method must not rely on + /// : it may not be relevant for the + /// . + /// + bool IsInActiveSystemTransaction(ISessionImplementor session); + + /// + /// Execute a work outside of the current transaction (if any). + /// + /// The session for which an isolated work has to be executed. + /// The work to execute. + /// for encapsulating the work in a dedicated + /// transaction, for not transacting it. void ExecuteWorkInIsolation(ISessionImplementor session, IIsolatedWork work, bool transacted); } } \ No newline at end of file diff --git a/src/NHibernate/nhibernate-configuration.xsd b/src/NHibernate/nhibernate-configuration.xsd index 39a8cc84a0e..cf34eca11dc 100644 --- a/src/NHibernate/nhibernate-configuration.xsd +++ b/src/NHibernate/nhibernate-configuration.xsd @@ -121,6 +121,33 @@ + + + + Timeout duration in milliseconds for the system transaction completion lock. + + When a system transaction completes, it may have its completion events running on concurrent threads, + after scope disposal. This occurs when the transaction is distributed. + This notably concerns ISessionImplementor.AfterTransactionCompletion(bool, ITransaction). + NHibernate protects the session from being concurrently used by the code following the scope disposal + with a lock. To prevent any application freeze, this lock has a default timeout of five seconds. If the + application appears to require longer (!) running transaction completion events, this setting allows to + raise this timeout. -1 disables the timeout. + + + + + + + When a system transaction is being prepared/prepared, is using connection during this process enabled? + Default is true, for supporting FlushMode.Commit with transaction factories + supporting system transactions. But this requires enlisting additional connections, retaining disposed + sessions and their connections till transaction end, and may trigger undesired transaction promotions to + distributed. Set to false for disabling using connections from system + transaction preparation, while still benefiting from FlushMode.Auto on querying. + + +