Skip to content

Commit 591ef23

Browse files
Fix concurrency issues on TransactionScope timeout
fix #3355
1 parent 37f3572 commit 591ef23

File tree

5 files changed

+246
-22
lines changed

5 files changed

+246
-22
lines changed

src/NHibernate.Test/Async/SystemTransactions/SystemTransactionFixture.cs

+112
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
using NHibernate.Engine;
2020
using NHibernate.Test.TransactionTest;
2121
using NUnit.Framework;
22+
23+
using SysTran = System.Transactions;
2224
using NHibernate.Linq;
2325

2426
namespace NHibernate.Test.SystemTransactions
@@ -524,6 +526,116 @@ public async Task EnforceConnectionUsageRulesOnTransactionCompletionAsync()
524526
// Currently always forbidden, whatever UseConnectionOnSystemTransactionEvents.
525527
Assert.That(interceptor.AfterException, Is.TypeOf<HibernateException>());
526528
}
529+
530+
// This test check a concurrency issue hard to reproduce. If it is flaky, it has to be considered failing.
531+
// In such case, raise triesCount to investigate it locally with more chances of triggering the trouble.
532+
[Test]
533+
public async Task SupportsTransactionTimeoutAsync()
534+
{
535+
// Test case adapted from https://github.com/kaksmet/NHibBugRepro
536+
537+
// Create some test data.
538+
const int entitiesCount = 5000;
539+
using (var s = OpenSession())
540+
using (var t = s.BeginTransaction())
541+
{
542+
for (var i = 0; i < entitiesCount; i++)
543+
{
544+
var person = new Person
545+
{
546+
NotNullData = Guid.NewGuid().ToString()
547+
};
548+
549+
await (s.SaveAsync(person));
550+
}
551+
552+
await (t.CommitAsync());
553+
}
554+
555+
// Setup unhandler exception catcher
556+
_unhandledExceptionCount = 0;
557+
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
558+
try
559+
{
560+
// Generate transaction timeouts.
561+
const int triesCount = 100;
562+
var txOptions = new TransactionOptions { Timeout = TimeSpan.FromMilliseconds(1) };
563+
var timeoutsCount = 0;
564+
for (var i = 0; i < triesCount; i++)
565+
{
566+
try
567+
{
568+
using var txScope = new TransactionScope(TransactionScopeOption.Required, txOptions, TransactionScopeAsyncFlowOption.Enabled);
569+
using var session = OpenSession();
570+
var data = await (session.CreateCriteria<Person>().ListAsync());
571+
Assert.That(data, Has.Count.EqualTo(entitiesCount));
572+
await (Task.Delay(2));
573+
var count = await (session.Query<Person>().CountAsync());
574+
Assert.That(count, Is.EqualTo(entitiesCount));
575+
txScope.Complete();
576+
}
577+
catch (Exception ex)
578+
{
579+
var currentEx = ex;
580+
// Depending on where the transaction aborption has broken NHibernate processing, we may
581+
// get various exceptions, like directly a TransactionAbortedException with an inner
582+
// TimeoutException, or a HibernateException encapsulating a TransactionException with a
583+
// timeout, ...
584+
bool isTransactionException;
585+
do
586+
{
587+
isTransactionException = currentEx is SysTran.TransactionException;
588+
currentEx = currentEx.InnerException;
589+
}
590+
while (!isTransactionException && currentEx != null);
591+
bool isTimeout;
592+
do
593+
{
594+
isTimeout = currentEx is TimeoutException;
595+
currentEx = currentEx?.InnerException;
596+
}
597+
while (!isTimeout && currentEx != null);
598+
599+
if (!isTimeout)
600+
{
601+
// We may also get a GenericADOException with an InvalidOperationException stating the
602+
// transaction associated to the connection is no more active but not yet suppressed,
603+
// and that for executing some SQL, we need to suppress it. That is a weak way of
604+
// identifying the case, especially with the possibility of localization of the message.
605+
currentEx = ex;
606+
do
607+
{
608+
isTimeout = currentEx is InvalidOperationException && currentEx.Message.Contains("SQL");
609+
currentEx = currentEx?.InnerException;
610+
}
611+
while (!isTimeout && currentEx != null);
612+
}
613+
614+
if (isTimeout)
615+
timeoutsCount++;
616+
else
617+
throw;
618+
}
619+
}
620+
621+
// Despite the Thread sleep and the count of entities to load, this test does get the timeout only for slightly
622+
// more than 10% of the attempts.
623+
Assert.That(timeoutsCount, Is.GreaterThan(5));
624+
Assert.That(_unhandledExceptionCount, Is.EqualTo(0));
625+
}
626+
finally
627+
{
628+
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
629+
}
630+
}
631+
632+
private int _unhandledExceptionCount;
633+
634+
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
635+
{
636+
_unhandledExceptionCount++;
637+
Assert.Warn("Unhandled exception: {0}", e.ExceptionObject);
638+
}
527639
}
528640

529641
[TestFixture]

src/NHibernate.Test/SystemTransactions/SystemTransactionFixture.cs

+95-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
using NHibernate.Test.TransactionTest;
1111
using NUnit.Framework;
1212

13+
using SysTran = System.Transactions;
14+
1315
namespace NHibernate.Test.SystemTransactions
1416
{
1517
[TestFixture]
@@ -643,24 +645,114 @@ public void CanUseSessionWithManyDependentTransaction(bool explicitFlush)
643645
}
644646
}
645647

648+
// This test check a concurrency issue hard to reproduce. If it is flaky, it has to be considered failing.
649+
// In such case, raise triesCount to investigate it locally with more chances of triggering the trouble.
646650
[Test]
647651
public void SupportsTransactionTimeout()
648652
{
649653
// Test case adapted from https://github.com/kaksmet/NHibBugRepro
654+
655+
// Create some test data.
656+
const int entitiesCount = 5000;
650657
using (var s = OpenSession())
651658
using (var t = s.BeginTransaction())
652659
{
653-
for (var i = 0; i < 5000; i++)
660+
for (var i = 0; i < entitiesCount; i++)
654661
{
655-
var person = new Person();
662+
var person = new Person
663+
{
664+
NotNullData = Guid.NewGuid().ToString()
665+
};
666+
656667
s.Save(person);
657668
}
658669

659670
t.Commit();
660671
}
661672

662-
var txOptions = new TransactionOptions { Timeout = TimeSpan.FromMilliseconds(1) };
673+
// Setup unhandler exception catcher
674+
_unhandledExceptionCount = 0;
675+
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
676+
try
677+
{
678+
// Generate transaction timeouts.
679+
const int triesCount = 100;
680+
var txOptions = new TransactionOptions { Timeout = TimeSpan.FromMilliseconds(1) };
681+
var timeoutsCount = 0;
682+
for (var i = 0; i < triesCount; i++)
683+
{
684+
try
685+
{
686+
using var txScope = new TransactionScope(TransactionScopeOption.Required, txOptions);
687+
using var session = OpenSession();
688+
var data = session.CreateCriteria<Person>().List();
689+
Assert.That(data, Has.Count.EqualTo(entitiesCount));
690+
Thread.Sleep(2);
691+
var count = session.Query<Person>().Count();
692+
Assert.That(count, Is.EqualTo(entitiesCount));
693+
txScope.Complete();
694+
}
695+
catch (Exception ex)
696+
{
697+
var currentEx = ex;
698+
// Depending on where the transaction aborption has broken NHibernate processing, we may
699+
// get various exceptions, like directly a TransactionAbortedException with an inner
700+
// TimeoutException, or a HibernateException encapsulating a TransactionException with a
701+
// timeout, ...
702+
bool isTransactionException;
703+
do
704+
{
705+
isTransactionException = currentEx is SysTran.TransactionException;
706+
currentEx = currentEx.InnerException;
707+
}
708+
while (!isTransactionException && currentEx != null);
709+
bool isTimeout;
710+
do
711+
{
712+
isTimeout = currentEx is TimeoutException;
713+
currentEx = currentEx?.InnerException;
714+
}
715+
while (!isTimeout && currentEx != null);
716+
717+
if (!isTimeout)
718+
{
719+
// We may also get a GenericADOException with an InvalidOperationException stating the
720+
// transaction associated to the connection is no more active but not yet suppressed,
721+
// and that for executing some SQL, we need to suppress it. That is a weak way of
722+
// identifying the case, especially with the possibility of localization of the message.
723+
currentEx = ex;
724+
do
725+
{
726+
isTimeout = currentEx is InvalidOperationException && currentEx.Message.Contains("SQL");
727+
currentEx = currentEx?.InnerException;
728+
}
729+
while (!isTimeout && currentEx != null);
730+
}
731+
732+
if (isTimeout)
733+
timeoutsCount++;
734+
else
735+
throw;
736+
}
737+
}
738+
739+
// Despite the Thread sleep and the count of entities to load, this test does get the timeout only for slightly
740+
// more than 10% of the attempts.
741+
Assert.That(timeoutsCount, Is.GreaterThan(5));
742+
Assert.That(_unhandledExceptionCount, Is.EqualTo(0));
743+
}
744+
finally
745+
{
746+
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
747+
}
748+
}
749+
750+
private int _unhandledExceptionCount;
663751

752+
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
753+
{
754+
_unhandledExceptionCount++;
755+
Assert.Warn("Unhandled exception: {0}", e.ExceptionObject);
664756
}
665757

666758
[Theory, Explicit("Bench")]

src/NHibernate/Async/Transaction/AdoNetWithSystemTransactionFactory.cs

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
using System;
1212
using System.Collections.Generic;
13+
using System.Diagnostics;
1314
using System.Linq;
1415
using System.Threading;
1516
using System.Transactions;

src/NHibernate/Impl/AbstractSessionImpl.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ private sealed class ProcessHelper : IDisposable
429429
private IDisposable _context;
430430

431431
[NonSerialized]
432-
private bool _processing;
432+
private volatile bool _processing;
433433

434434
public ProcessHelper()
435435
{

src/NHibernate/Transaction/AdoNetWithSystemTransactionFactory.cs

+37-18
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics;
34
using System.Linq;
45
using System.Threading;
56
using System.Transactions;
@@ -418,23 +419,20 @@ void IEnlistmentNotification.InDoubt(Enlistment enlistment)
418419
/// callback, <see langword="null"/> if this is an in-doubt callback.</param>
419420
protected virtual void ProcessSecondPhase(Enlistment enlistment, bool? success)
420421
{
421-
using (_session.BeginContext())
422-
{
423-
_logger.Debug(
424-
success.HasValue
425-
? success.Value
426-
? "Committing system transaction"
427-
: "Rolled back system transaction"
428-
: "System transaction is in doubt");
422+
_logger.Debug(
423+
success.HasValue
424+
? success.Value
425+
? "Committing system transaction"
426+
: "Rolled back system transaction"
427+
: "System transaction is in doubt");
429428

430-
try
431-
{
432-
CompleteTransaction(success ?? false);
433-
}
434-
finally
435-
{
436-
enlistment.Done();
437-
}
429+
try
430+
{
431+
CompleteTransaction(success ?? false);
432+
}
433+
finally
434+
{
435+
enlistment.Done();
438436
}
439437
}
440438

@@ -458,7 +456,28 @@ protected virtual void CompleteTransaction(bool isCommitted)
458456
{
459457
// Allow transaction completed actions to run while others stay blocked.
460458
_bypassLock.Value = true;
461-
using (_session.BeginContext())
459+
// Ensure no other session processing is still ongoing. In case of a transaction timeout, the transaction is
460+
// cancelled on a new thread even for non-distributed scopes. So, the session could be doing some processing,
461+
// and will not be interrupted until attempting some usage of the connection. See #3355.
462+
// Thread safety of a concurrent session BeginProcess is ensured by the Wait performed by BeginProcess.
463+
var context = _session.BeginProcess();
464+
if (context == null)
465+
{
466+
var timeOutGuard = new Stopwatch();
467+
timeOutGuard.Start();
468+
while (context == null && timeOutGuard.ElapsedMilliseconds < _systemTransactionCompletionLockTimeout)
469+
{
470+
// Naïve yield.
471+
Thread.Sleep(10);
472+
context = _session.BeginProcess();
473+
}
474+
if (context == null)
475+
throw new HibernateException(
476+
$"Synchronization timeout for transaction completion. Either raise" +
477+
$"{Cfg.Environment.SystemTransactionCompletionLockTimeout}, or check all scopes are properly" +
478+
$"disposed and/or all direct System.Transaction.Current changes are properly managed.");
479+
}
480+
using (context)
462481
{
463482
// Flag active as false before running actions, otherwise the session may not cleanup as much
464483
// as possible.
@@ -477,7 +496,7 @@ protected virtual void CompleteTransaction(bool isCommitted)
477496
// within scopes, although mixing is not advised.
478497
if (!ShouldCloseSessionOnSystemTransactionCompleted)
479498
_session.ConnectionManager.EnlistIfRequired(null);
480-
499+
481500
_session.AfterTransactionCompletion(isCommitted, null);
482501
foreach (var dependentSession in _session.ConnectionManager.DependentSessions)
483502
dependentSession.AfterTransactionCompletion(isCommitted, null);

0 commit comments

Comments
 (0)