Files
Umbraco-CMS/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/LocksTests.cs
2022-01-18 15:23:53 +00:00

541 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using NPoco;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence
{
[TestFixture]
[Timeout(60000)]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Console)]
public class LocksTests : UmbracoIntegrationTest
{
[SetUp]
protected void SetUp()
{
// create a few lock objects
using (var scope = ScopeProvider.CreateScope())
{
var database = scope.Database;
database.Insert("umbracoLock", "id", false, new LockDto { Id = 1, Name = "Lock.1" });
database.Insert("umbracoLock", "id", false, new LockDto { Id = 2, Name = "Lock.2" });
database.Insert("umbracoLock", "id", false, new LockDto { Id = 3, Name = "Lock.3" });
scope.Complete();
}
}
[Test]
public void SingleReadLockTest()
{
using (var scope = ScopeProvider.CreateScope())
{
scope.EagerReadLock(Constants.Locks.Servers);
scope.Complete();
}
}
[Test]
public void ConcurrentReadersTest()
{
const int threadCount = 8;
var threads = new Thread[threadCount];
var exceptions = new Exception[threadCount];
var locker = new object();
var acquired = 0;
var m2 = new ManualResetEventSlim(false);
var m1 = new ManualResetEventSlim(false);
for (var i = 0; i < threadCount; i++)
{
var ic = i; // capture
threads[i] = new Thread(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
try
{
scope.EagerReadLock(Constants.Locks.Servers);
lock (locker)
{
acquired++;
if (acquired == threadCount) m2.Set();
}
m1.Wait();
lock (locker)
{
acquired--;
}
}
catch (Exception e)
{
exceptions[ic] = e;
}
scope.Complete();
}
});
}
// ensure that current scope does not leak into starting threads
using (ExecutionContext.SuppressFlow())
{
foreach (var thread in threads)
{
thread.Start();
}
}
m2.Wait();
// all threads have locked in parallel
var maxAcquired = acquired;
m1.Set();
foreach (var thread in threads)
{
thread.Join();
}
Assert.AreEqual(threadCount, maxAcquired);
Assert.AreEqual(0, acquired);
for (var i = 0; i < threadCount; i++)
{
Assert.IsNull(exceptions[i]);
}
}
[Test]
public void GivenNonEagerLocking_WhenNoDbIsAccessed_ThenNoSqlIsExecuted()
{
var sqlCount = 0;
using (var scope = ScopeProvider.CreateScope())
{
var db = scope.Database;
try
{
db.EnableSqlCount = true;
// Issue a lock request, but we are using non-eager
// locks so this only queues the request.
// The lock will not be issued unless we resolve
// scope.Database
scope.WriteLock(Constants.Locks.Servers);
sqlCount = db.SqlCount;
}
finally
{
db.EnableSqlCount = false;
}
}
Assert.AreEqual(0, sqlCount);
}
[Test]
public void ConcurrentWritersTest()
{
const int threadCount = 8;
var threads = new Thread[threadCount];
var exceptions = new Exception[threadCount];
var locker = new object();
var acquired = 0;
var entered = 0;
var ms = new AutoResetEvent[threadCount];
for (var i = 0; i < threadCount; i++)
{
ms[i] = new AutoResetEvent(false);
}
var m1 = new ManualResetEventSlim(false);
for (var i = 0; i < threadCount; i++)
{
var ic = i; // capture
threads[i] = new Thread(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
try
{
lock (locker)
{
entered++;
if (entered == threadCount) m1.Set();
}
ms[ic].WaitOne();
scope.EagerWriteLock(Constants.Locks.Servers);
lock (locker)
{
acquired++;
}
ms[ic].WaitOne();
lock (locker)
{
acquired--;
}
}
catch (Exception e)
{
exceptions[ic] = e;
}
scope.Complete();
}
});
}
// ensure that current scope does not leak into starting threads
using (ExecutionContext.SuppressFlow())
{
foreach (var thread in threads)
{
thread.Start();
}
}
m1.Wait();
// all threads have entered
ms[0].Set(); // let 0 go
// TODO: This timing is flaky
Thread.Sleep(100);
for (var i = 1; i < threadCount; i++)
{
ms[i].Set(); // let others go
}
// TODO: This timing is flaky
Thread.Sleep(500);
// only 1 thread has locked
Assert.AreEqual(1, acquired);
for (var i = 0; i < threadCount; i++)
{
ms[i].Set(); // let all go
}
foreach (var thread in threads)
{
thread.Join();
}
Assert.AreEqual(0, acquired);
for (var i = 0; i < threadCount; i++)
{
Assert.IsNull(exceptions[i]);
}
}
[Retry(10)] // TODO make this test non-flaky.
[Test]
public void DeadLockTest()
{
Exception e1 = null, e2 = null;
AutoResetEvent ev1 = new AutoResetEvent(false), ev2 = new AutoResetEvent(false);
// testing:
// two threads will each obtain exclusive write locks over two
// identical lock objects deadlock each other
var thread1 = new Thread(() => DeadLockTestThread(1, 2, ev1, ev2, ref e1));
var thread2 = new Thread(() => DeadLockTestThread(2, 1, ev2, ev1, ref e2));
// ensure that current scope does not leak into starting threads
using (ExecutionContext.SuppressFlow())
{
thread1.Start();
thread2.Start();
}
ev2.Set();
thread1.Join();
thread2.Join();
//Assert.IsNotNull(e1);
if (e1 != null)
{
AssertIsSqlLockException(e1);
}
// the assertion below depends on timing conditions - on a fast enough environment,
// thread1 dies (deadlock) and frees thread2, which succeeds - however on a slow
// environment (CI) both threads can end up dying due to deadlock - so, cannot test
// that e2 is null - but if it's not, can test that it's a timeout
//
//Assert.IsNull(e2);
if (e2 != null)
{
AssertIsSqlLockException(e2);
}
}
private void AssertIsSqlLockException(Exception e)
{
var sqlException = e as SqlException;
Assert.IsNotNull(sqlException);
Assert.AreEqual(1222, sqlException.Number);
}
private void DeadLockTestThread(int id1, int id2, EventWaitHandle myEv, WaitHandle otherEv, ref Exception exception)
{
using (var scope = ScopeProvider.CreateScope())
{
try
{
otherEv.WaitOne();
Console.WriteLine($"[{id1}] WAIT {id1}");
scope.EagerWriteLock(id1);
Console.WriteLine($"[{id1}] GRANT {id1}");
WriteLocks(scope.Database);
myEv.Set();
if (id1 == 1)
{
otherEv.WaitOne();
}
else
{
Thread.Sleep(5200); // wait for deadlock...
}
Console.WriteLine($"[{id1}] WAIT {id2}");
scope.EagerWriteLock(id2);
Console.WriteLine($"[{id1}] GRANT {id2}");
WriteLocks(scope.Database);
}
catch (Exception e)
{
exception = e;
}
finally
{
scope.Complete();
}
}
}
[Test]
public void NoDeadLockTest()
{
Exception e1 = null, e2 = null;
AutoResetEvent ev1 = new AutoResetEvent(false), ev2 = new AutoResetEvent(false);
// testing:
// two threads will each obtain exclusive write lock over two
// different lock objects without blocking each other
var thread1 = new Thread(() => NoDeadLockTestThread(1, ev1, ev2, ref e1));
var thread2 = new Thread(() => NoDeadLockTestThread(2, ev2, ev1, ref e1));
// ensure that current scope does not leak into starting threads
using (ExecutionContext.SuppressFlow())
{
thread1.Start();
thread2.Start();
}
ev2.Set();
thread1.Join();
thread2.Join();
Assert.IsNull(e1);
Assert.IsNull(e2);
}
[Test]
public void Throws_When_Lock_Timeout_Is_Exceeded()
{
using (ExecutionContext.SuppressFlow())
{
var t1 = Task.Run(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
Console.WriteLine("Write lock A");
// This will acquire right away
scope.EagerWriteLock(TimeSpan.FromMilliseconds(2000), Constants.Locks.ContentTree);
Thread.Sleep(6000); // Wait longer than the Read Lock B timeout
scope.Complete();
Console.WriteLine("Finished Write lock A");
}
});
Thread.Sleep(500); // 100% sure task 1 starts first
var t2 = Task.Run(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
Console.WriteLine("Read lock B");
// This will wait for the write lock to release but it isn't going to wait long
// enough so an exception will be thrown.
Assert.Throws<SqlException>(() =>
scope.EagerReadLock(TimeSpan.FromMilliseconds(3000), Constants.Locks.ContentTree));
scope.Complete();
Console.WriteLine("Finished Read lock B");
}
});
var t3 = Task.Run(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
Console.WriteLine("Write lock C");
// This will wait for the write lock to release but it isn't going to wait long
// enough so an exception will be thrown.
Assert.Throws<SqlException>(() =>
scope.EagerWriteLock(TimeSpan.FromMilliseconds(3000), Constants.Locks.ContentTree));
scope.Complete();
Console.WriteLine("Finished Write lock C");
}
});
Task.WaitAll(t1, t2, t3);
}
}
[Test]
public void Read_Lock_Waits_For_Write_Lock()
{
var locksCompleted = 0;
using (ExecutionContext.SuppressFlow())
{
var t1 = Task.Run(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
Console.WriteLine("Write lock A");
// This will acquire right away
scope.EagerWriteLock(TimeSpan.FromMilliseconds(2000), Constants.Locks.ContentTree);
Thread.Sleep(4000); // Wait less than the Read Lock B timeout
scope.Complete();
Interlocked.Increment(ref locksCompleted);
Console.WriteLine("Finished Write lock A");
}
});
Thread.Sleep(500); // 100% sure task 1 starts first
var t2 = Task.Run(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
Console.WriteLine("Read lock B");
// This will wait for the write lock to release
Assert.DoesNotThrow(() =>
scope.EagerReadLock(TimeSpan.FromMilliseconds(6000), Constants.Locks.ContentTree));
Assert.GreaterOrEqual(locksCompleted, 1);
scope.Complete();
Interlocked.Increment(ref locksCompleted);
Console.WriteLine("Finished Read lock B");
}
});
var t3 = Task.Run(() =>
{
using (var scope = ScopeProvider.CreateScope())
{
Console.WriteLine("Read lock C");
// This will wait for the write lock to release
Assert.DoesNotThrow(() =>
scope.EagerReadLock(TimeSpan.FromMilliseconds(6000), Constants.Locks.ContentTree));
Assert.GreaterOrEqual(locksCompleted, 1);
scope.Complete();
Interlocked.Increment(ref locksCompleted);
Console.WriteLine("Finished Read lock C");
}
});
Task.WaitAll(t1, t2, t3);
}
Assert.AreEqual(3, locksCompleted);
}
[Test]
public void Lock_Exceeds_Command_Timeout()
{
using (var scope = ScopeProvider.CreateScope())
{
var realDb = (Database)scope.Database;
realDb.CommandTimeout = 1000;
Console.WriteLine("Write lock A");
// TODO: In theory this would throw
scope.EagerWriteLock(TimeSpan.FromMilliseconds(3000), Constants.Locks.ContentTree);
scope.Complete();
Console.WriteLine("Finished Write lock A");
}
}
private void NoDeadLockTestThread(int id, EventWaitHandle myEv, WaitHandle otherEv, ref Exception exception)
{
using (var scope = ScopeProvider.CreateScope())
{
try
{
otherEv.WaitOne();
Console.WriteLine($"[{id}] WAIT {id}");
scope.EagerWriteLock(id);
Console.WriteLine($"[{id}] GRANT {id}");
WriteLocks(scope.Database);
myEv.Set();
otherEv.WaitOne();
}
catch (Exception e)
{
exception = e;
}
finally
{
scope.Complete();
myEv.Set();
}
}
}
private void WriteLocks(IDatabaseQuery database)
{
Console.WriteLine("LOCKS:");
var info = database.Query<dynamic>("SELECT * FROM sys.dm_tran_locks;").ToList();
var sb = new StringBuilder("> ");
foreach (var row in info)
{
if (row is IDictionary<string, object> values)
{
sb.AppendJoin(", ", values);
}
sb.AppendLine(string.Empty);
}
Console.WriteLine(sb.ToString());
}
}
}