99
1010
1111using System ;
12+ using System . Collections . Concurrent ;
1213using System . Collections . Generic ;
1314using System . Diagnostics ;
1415using System . Linq ;
@@ -30,6 +31,13 @@ public class SystemTransactionFixtureAsync : SystemTransactionFixtureBase
3031 protected override bool UseConnectionOnSystemTransactionPrepare => true ;
3132 protected override bool AutoJoinTransaction => true ;
3233
34+ protected override void OnTearDown ( )
35+ {
36+ base . OnTearDown ( ) ;
37+ // The SupportsTransactionTimeout test may change this, restore it to its default value.
38+ FailOnNotClosedSession = true ;
39+ }
40+
3341 [ Test ]
3442 public async Task WillNotCrashOnPrepareFailureAsync ( )
3543 {
@@ -524,6 +532,152 @@ public async Task EnforceConnectionUsageRulesOnTransactionCompletionAsync()
524532 // Currently always forbidden, whatever UseConnectionOnSystemTransactionEvents.
525533 Assert . That ( interceptor . AfterException , Is . TypeOf < HibernateException > ( ) ) ;
526534 }
535+
536+ // This test check a concurrency issue hard to reproduce. If it is flaky, it has to be considered failing.
537+ // In such case, raise triesCount to investigate it locally with more chances of triggering the trouble.
538+ [ Test ]
539+ public async Task SupportsTransactionTimeoutAsync ( )
540+ {
541+ Assume . That ( TestDialect . SupportsTransactionScopeTimeouts , Is . True , "The tested dialect is not supported for transaction scope timeouts." ) ;
542+ // Other special cases: ODBC and SAP SQL Anywhere succeed this test only with transaction.ignore_session_synchronization_failures
543+ // enabled.
544+ // They freeze the session during the transaction cancellation. To avoid the test to be very long, the synchronization
545+ // lock timeout should be lowered too.
546+
547+ // A concurrency issue exists with the legacy setting allowing to use the session from transaction completion, which
548+ // may cause session leaks. Ignore them.
549+ FailOnNotClosedSession = ! UseConnectionOnSystemTransactionPrepare ;
550+
551+ // Test case adapted from https://github.com/kaksmet/NHibBugRepro
552+
553+ // Create some test data.
554+ const int entitiesCount = 5000 ;
555+ using ( var s = OpenSession ( ) )
556+ using ( var t = s . BeginTransaction ( ) )
557+ {
558+ for ( var i = 0 ; i < entitiesCount ; i ++ )
559+ {
560+ var person = new Person
561+ {
562+ NotNullData = Guid . NewGuid ( ) . ToString ( )
563+ } ;
564+
565+ await ( s . SaveAsync ( person ) ) ;
566+ }
567+
568+ await ( t . CommitAsync ( ) ) ;
569+ }
570+
571+ // Setup unhandled exception catcher.
572+ _unhandledExceptions = new ConcurrentBag < object > ( ) ;
573+ AppDomain . CurrentDomain . UnhandledException += CurrentDomain_UnhandledException ;
574+ try
575+ {
576+ // Generate transaction timeouts.
577+ const int triesCount = 100 ;
578+ var txOptions = new TransactionOptions { Timeout = TimeSpan . FromMilliseconds ( 1 ) } ;
579+ var timeoutsCount = 0 ;
580+ for ( var i = 0 ; i < triesCount ; i ++ )
581+ {
582+ try
583+ {
584+ using var txScope = new TransactionScope ( TransactionScopeOption . Required , txOptions , TransactionScopeAsyncFlowOption . Enabled ) ;
585+ using var session = OpenSession ( ) ;
586+ var data = await ( session . CreateCriteria < Person > ( ) . ListAsync ( ) ) ;
587+ Assert . That ( data , Has . Count . EqualTo ( entitiesCount ) , "Unexpected count of loaded entities." ) ;
588+ await ( Task . Delay ( 2 ) ) ;
589+ var count = await ( session . Query < Person > ( ) . CountAsync ( ) ) ;
590+ Assert . That ( count , Is . EqualTo ( entitiesCount ) , "Unexpected entities count." ) ;
591+ txScope . Complete ( ) ;
592+ }
593+ catch
594+ {
595+ // Assume that is a transaction timeout. It may cause various failures, of which some are hard to identify.
596+ timeoutsCount ++ ;
597+ }
598+ // If in need of checking some specific failures, the following code may be used instead:
599+ /*
600+ catch (Exception ex)
601+ {
602+ var currentEx = ex;
603+ // Depending on where the transaction aborption has broken NHibernate processing, we may
604+ // get various exceptions, like directly a TransactionAbortedException with an inner
605+ // TimeoutException, or a HibernateException encapsulating a TransactionException with a
606+ // timeout, ...
607+ bool isTransactionException, isTimeout;
608+ do
609+ {
610+ isTransactionException = currentEx is System.Transactions.TransactionException;
611+ isTimeout = isTransactionException && currentEx is TransactionAbortedException;
612+ currentEx = currentEx.InnerException;
613+ }
614+ while (!isTransactionException && currentEx != null);
615+ while (!isTimeout && currentEx != null)
616+ {
617+ isTimeout = currentEx is TimeoutException;
618+ currentEx = currentEx?.InnerException;
619+ }
620+
621+ if (!isTimeout)
622+ {
623+ // We may also get a GenericADOException with an InvalidOperationException stating the
624+ // transaction associated to the connection is no more active but not yet suppressed,
625+ // and that for executing some SQL, we need to suppress it. That is a weak way of
626+ // identifying the case, especially with the many localizations of the message.
627+ currentEx = ex;
628+ do
629+ {
630+ isTimeout = currentEx is InvalidOperationException && currentEx.Message.Contains("SQL");
631+ currentEx = currentEx?.InnerException;
632+ }
633+ while (!isTimeout && currentEx != null);
634+ }
635+
636+ if (isTimeout)
637+ timeoutsCount++;
638+ else
639+ throw;
640+ }
641+ */
642+ }
643+
644+ Assert . That (
645+ _unhandledExceptions . Count ,
646+ Is . EqualTo ( 0 ) ,
647+ "Unhandled exceptions have occurred: {0}" ,
648+ string . Join ( @"
649+
650+ " , _unhandledExceptions ) ) ;
651+
652+ // Despite the Thread sleep and the count of entities to load, this test may get the timeout only for slightly
653+ // more than 10% of the attempts.
654+ Warn . Unless ( timeoutsCount , Is . GreaterThan ( 5 ) , "The test should have generated more timeouts." ) ;
655+ }
656+ finally
657+ {
658+ AppDomain . CurrentDomain . UnhandledException -= CurrentDomain_UnhandledException ;
659+ }
660+ }
661+
662+ private ConcurrentBag < object > _unhandledExceptions ;
663+
664+ private void CurrentDomain_UnhandledException ( object sender , UnhandledExceptionEventArgs e )
665+ {
666+ if ( e . ExceptionObject is Exception exception )
667+ {
668+ // Ascertain NHibernate is involved. Some unhandled exceptions occur due to the
669+ // TransactionScope timeout operating on an unexpected thread for the data provider.
670+ var isNHibernateInvolved = false ;
671+ while ( exception != null && ! isNHibernateInvolved )
672+ {
673+ isNHibernateInvolved = exception . StackTrace != null && exception . StackTrace . ToLowerInvariant ( ) . Contains ( "nhibernate" ) ;
674+ exception = exception . InnerException ;
675+ }
676+ if ( ! isNHibernateInvolved )
677+ return ;
678+ }
679+ _unhandledExceptions . Add ( e . ExceptionObject ) ;
680+ }
527681 }
528682
529683 [ TestFixture ]
0 commit comments