From 1ed7fe8bbebb6bb1c0ae02ab4115d3a6ad637917 Mon Sep 17 00:00:00 2001 From: Paul Reardon Date: Thu, 16 May 2024 09:08:50 +0100 Subject: [PATCH] Initial suggestion for Global locking for Timed Services for v9 issue #3075 --- Brighter.sln | 26 +++++ .../AzureBlobLockingProvider.cs | 106 ++++++++++++++++++ .../AzureBlobLockingProviderOptions.cs | 29 +++++ .../Paramore.Brighter.Locking.Azure.csproj | 19 ++++ .../ServiceCollectionExtensions.cs | 13 +++ .../HostedServiceCollectionExtensions.cs | 7 ++ .../TimedOutboxArchiver.cs | 15 ++- .../TimedOutboxSweeper.cs | 60 ++++++---- src/Paramore.Brighter/IDistributedLock.cs | 38 +++++++ src/Paramore.Brighter/InMemoryLock.cs | 44 ++++++++ .../AzureBlobLockingProviderTests.cs | 45 ++++++++ .../Paramore.Brighter.Azure.Tests.csproj | 1 + .../Locking/InMemoryLockingProviderTests.cs | 39 +++++++ 13 files changed, 412 insertions(+), 30 deletions(-) create mode 100644 Paramore.Brighter.Locking.Azure/AzureBlobLockingProvider.cs create mode 100644 Paramore.Brighter.Locking.Azure/AzureBlobLockingProviderOptions.cs create mode 100644 Paramore.Brighter.Locking.Azure/Paramore.Brighter.Locking.Azure.csproj create mode 100644 src/Paramore.Brighter/IDistributedLock.cs create mode 100644 src/Paramore.Brighter/InMemoryLock.cs create mode 100644 tests/Paramore.Brighter.Azure.Tests/AzureBlobLockingProviderTests.cs create mode 100644 tests/Paramore.Brighter.Core.Tests/Locking/InMemoryLockingProviderTests.cs diff --git a/Brighter.sln b/Brighter.sln index 9953bde5f1..be56a53c42 100644 --- a/Brighter.sln +++ b/Brighter.sln @@ -345,6 +345,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Paramore.Brighter.ServiceAc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Paramore.Brighter.ServiceActivator.Control.Api", "src\Paramore.Brighter.ServiceActivator.Control.Api\Paramore.Brighter.ServiceActivator.Control.Api.csproj", "{397F8496-6916-43EF-AEB2-5D84048DE357}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Paramore.Brighter.Locking.Azure", "Paramore.Brighter.Locking.Azure\Paramore.Brighter.Locking.Azure.csproj", "{021F3B51-A640-4C0D-9B47-FB4E32DF6715}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1987,6 +1989,30 @@ Global {397F8496-6916-43EF-AEB2-5D84048DE357}.Release|Mixed Platforms.Build.0 = Release|Any CPU {397F8496-6916-43EF-AEB2-5D84048DE357}.Release|x86.ActiveCfg = Release|Any CPU {397F8496-6916-43EF-AEB2-5D84048DE357}.Release|x86.Build.0 = Release|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Debug|x86.ActiveCfg = Debug|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Debug|x86.Build.0 = Debug|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Release|Any CPU.Build.0 = Release|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Release|x86.ActiveCfg = Release|Any CPU + {3384FBF0-5DCB-452D-8288-FAD1D0023089}.Release|x86.Build.0 = Release|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Debug|Any CPU.Build.0 = Debug|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Debug|x86.ActiveCfg = Debug|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Debug|x86.Build.0 = Debug|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Release|Any CPU.ActiveCfg = Release|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Release|Any CPU.Build.0 = Release|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Release|x86.ActiveCfg = Release|Any CPU + {021F3B51-A640-4C0D-9B47-FB4E32DF6715}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Paramore.Brighter.Locking.Azure/AzureBlobLockingProvider.cs b/Paramore.Brighter.Locking.Azure/AzureBlobLockingProvider.cs new file mode 100644 index 0000000000..94c3d1d827 --- /dev/null +++ b/Paramore.Brighter.Locking.Azure/AzureBlobLockingProvider.cs @@ -0,0 +1,106 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Specialized; +using Microsoft.Extensions.Logging; +using Paramore.Brighter.Logging; + +namespace Paramore.Brighter.Locking.Azure; + +public class AzureBlobLockingProvider(AzureBlobLockingProviderOptions options) : IDistributedLock +{ + private readonly BlobContainerClient _containerClient = new BlobContainerClient(options.BlobContainerUri, options.TokenCredential); + private readonly ILogger _logger = ApplicationLogging.CreateLogger(); + + private readonly Dictionary _leases = new Dictionary(); + + public async Task ObtainLockAsync(string resource, CancellationToken cancellationToken) + { + var client = GetBlobClient(resource); + + // Write if does not exist + if (!await client.ExistsAsync(cancellationToken)) + { + await using var emptyStream = new MemoryStream(); + await using var writer = new StreamWriter(emptyStream); + await writer.WriteAsync(string.Empty); + await writer.FlushAsync(cancellationToken); + emptyStream.Position = 0; + await client.UploadAsync(emptyStream, cancellationToken: cancellationToken); + } + + try + { + var response = await client.GetBlobLeaseClient().AcquireAsync(options.LeaseValidity, cancellationToken: cancellationToken); + _leases.Add(NormaliseResourceName(resource), response.Value.LeaseId); + return true; + } + catch (RequestFailedException e) + { + _logger.LogInformation("Could not Acquire Lease on Blob {LockResourceName}", resource); + return false; + } + } + + public bool ObtainLock(string resource) + { + var client = GetBlobClient(resource); + + // Write if does not exist + if (!client.Exists()) + { + using var emptyStream = new MemoryStream(); + using var writer = new StreamWriter(emptyStream); + writer.Write(string.Empty); + writer.Flush(); + emptyStream.Position = 0; + client.Upload(emptyStream); + } + + try + { + var response = client.GetBlobLeaseClient().Acquire(options.LeaseValidity); + _leases.Add(NormaliseResourceName(resource), response.Value.LeaseId); + return true; + } + catch (RequestFailedException e) + { + _logger.LogInformation("Could not Acquire Lease on Blob {LockResourceName}", resource); + return false; + } + } + + public async Task ReleaseLockAsync(string resource, CancellationToken cancellationToken) + { + var client = GetBlobLeaseClientForResource(resource); + if(client == null) + return; + await client.ReleaseAsync(cancellationToken: cancellationToken); + _leases.Remove(NormaliseResourceName(resource)); + } + + public void ReleaseLock(string resource) + { + var client = GetBlobLeaseClientForResource(resource); + if(client == null) + return; + client.Release(); + _leases.Remove(NormaliseResourceName(resource)); + } + + private BlobLeaseClient? GetBlobLeaseClientForResource(string resource) + { + if (_leases.ContainsKey(NormaliseResourceName(resource))) + return GetBlobClient(resource).GetBlobLeaseClient(_leases[NormaliseResourceName(resource)]); + + _logger.LogInformation("No lock found for {LockResourceName}", resource); + return null; + } + + private BlobClient GetBlobClient(string resource) + { + var storageLocation = options.StorageLocationFunc.Invoke(NormaliseResourceName(resource)); + return _containerClient.GetBlobClient(storageLocation); + } + + private static string NormaliseResourceName(string resourceName) => resourceName.ToLower(); +} diff --git a/Paramore.Brighter.Locking.Azure/AzureBlobLockingProviderOptions.cs b/Paramore.Brighter.Locking.Azure/AzureBlobLockingProviderOptions.cs new file mode 100644 index 0000000000..df012ca18a --- /dev/null +++ b/Paramore.Brighter.Locking.Azure/AzureBlobLockingProviderOptions.cs @@ -0,0 +1,29 @@ +using Azure.Core; + +namespace Paramore.Brighter.Locking.Azure; + +public class AzureBlobLockingProviderOptions( + Uri blobContainerUri, + TokenCredential tokenCredential + ) +{ + /// + /// The URI of the blob container + /// + public Uri BlobContainerUri { get; init; } = blobContainerUri; + + /// + /// The Credential to use when writing blobs + /// + public TokenCredential TokenCredential { get; init; } = tokenCredential; + + /// + /// The amount of time before the lease automatically expires + /// + public TimeSpan LeaseValidity { get; init; } = TimeSpan.FromMinutes(1); + + /// + /// The function to provide the location to store the locks inside of the Blob container + /// + public Func StorageLocationFunc = (resource) => $"lock-{resource}"; +} diff --git a/Paramore.Brighter.Locking.Azure/Paramore.Brighter.Locking.Azure.csproj b/Paramore.Brighter.Locking.Azure/Paramore.Brighter.Locking.Azure.csproj new file mode 100644 index 0000000000..14b3998485 --- /dev/null +++ b/Paramore.Brighter.Locking.Azure/Paramore.Brighter.Locking.Azure.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + This is the Azure Distributed Locking Provider. + Paul Reardon + + + + + + + + + + + diff --git a/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 9039e85c4f..d4c4b27cb0 100644 --- a/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -217,6 +217,19 @@ public static IBrighterBuilder UseExternalBus(this IBrighterBuilder brighterBuil return brighterBuilder; } + + /// + /// Use a distributed locking mechanism for processes that can not run in parallel + /// + /// The Brighter builder to add this option to + /// The Distributed Lock Provider + /// The Brighter builder to allow chaining of requests + public static IBrighterBuilder UseDistributedLock(this IBrighterBuilder brighterBuilder, IDistributedLock distributedLock) + { + brighterBuilder.Services.AddSingleton(distributedLock); + + return brighterBuilder; + } /// /// Configure a Feature Switch registry to control handlers to be feature switched at runtime diff --git a/src/Paramore.Brighter.Extensions.Hosting/HostedServiceCollectionExtensions.cs b/src/Paramore.Brighter.Extensions.Hosting/HostedServiceCollectionExtensions.cs index a26da51c7d..31e12c18ff 100644 --- a/src/Paramore.Brighter.Extensions.Hosting/HostedServiceCollectionExtensions.cs +++ b/src/Paramore.Brighter.Extensions.Hosting/HostedServiceCollectionExtensions.cs @@ -21,6 +21,10 @@ public static IBrighterBuilder UseOutboxSweeper(this IBrighterBuilder brighterBu brighterBuilder.Services.TryAddSingleton(options); brighterBuilder.Services.AddHostedService(); + + // If no distributed locking service is added, then add the in memory variant + brighterBuilder.Services.TryAddSingleton(new InMemoryLock()); + return brighterBuilder; } @@ -34,6 +38,9 @@ public static IBrighterBuilder UseOutboxArchiver(this IBrighterBuilder brighterB brighterBuilder.Services.AddSingleton(archiveProvider); brighterBuilder.Services.AddHostedService(); + + // If no distributed locking service is added, then add the in memory variant + brighterBuilder.Services.TryAddSingleton(new InMemoryLock()); return brighterBuilder; } diff --git a/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxArchiver.cs b/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxArchiver.cs index 446a59e165..2e9c7519f3 100644 --- a/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxArchiver.cs +++ b/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxArchiver.cs @@ -14,15 +14,17 @@ public class TimedOutboxArchiver : IHostedService, IDisposable private static readonly ILogger s_logger = ApplicationLogging.CreateLogger(); private IAmAnOutbox _outbox; private IAmAnArchiveProvider _archiveProvider; + private readonly IDistributedLock _distributedLock; private Timer _timer; - private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); - + private const string LockingResourceName = "Archiver"; + public TimedOutboxArchiver(IAmAnOutbox outbox, IAmAnArchiveProvider archiveProvider, - TimedOutboxArchiverOptions options) + IDistributedLock distributedLock, TimedOutboxArchiverOptions options) { _outbox = outbox; _archiveProvider = archiveProvider; + _distributedLock = distributedLock; _options = options; } @@ -30,7 +32,8 @@ public Task StartAsync(CancellationToken cancellationToken) { s_logger.LogInformation("Outbox Archiver Service is starting."); - _timer = new Timer(async (e) => await Archive(e, cancellationToken), null, TimeSpan.Zero, TimeSpan.FromSeconds(_options.TimerInterval)); + _timer = new Timer(async (e) => await Archive(e, cancellationToken), null, TimeSpan.Zero, + TimeSpan.FromSeconds(_options.TimerInterval)); return Task.CompletedTask; } @@ -51,7 +54,7 @@ public void Dispose() private async Task Archive(object state, CancellationToken cancellationToken) { - if (await _semaphore.WaitAsync(TimeSpan.Zero, cancellationToken)) + if (await _distributedLock.ObtainLockAsync(LockingResourceName, cancellationToken)) { s_logger.LogInformation("Outbox Archiver looking for messages to Archive"); try @@ -69,7 +72,7 @@ private async Task Archive(object state, CancellationToken cancellationToken) } finally { - _semaphore.Release(); + await _distributedLock.ReleaseLockAsync(LockingResourceName, cancellationToken); } s_logger.LogInformation("Outbox Sweeper sleeping"); diff --git a/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxSweeper.cs b/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxSweeper.cs index 36005de97a..84836b4bbe 100644 --- a/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxSweeper.cs +++ b/src/Paramore.Brighter.Extensions.Hosting/TimedOutboxSweeper.cs @@ -12,13 +12,17 @@ namespace Paramore.Brighter.Extensions.Hosting public class TimedOutboxSweeper : IHostedService, IDisposable { private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly IDistributedLock _distributedLock; private readonly TimedOutboxSweeperOptions _options; private static readonly ILogger s_logger= ApplicationLogging.CreateLogger(); private Timer _timer; - - public TimedOutboxSweeper (IServiceScopeFactory serviceScopeFactory, TimedOutboxSweeperOptions options) + private const string LockingResourceName = "OutboxSweeper"; + + public TimedOutboxSweeper(IServiceScopeFactory serviceScopeFactory, IDistributedLock distributedLock, + TimedOutboxSweeperOptions options) { _serviceScopeFactory = serviceScopeFactory; + _distributedLock = distributedLock; _options = options; } @@ -33,33 +37,41 @@ public Task StartAsync(CancellationToken cancellationToken) private void DoWork(object state) { - s_logger.LogInformation("Outbox Sweeper looking for unsent messages"); - - var scope = _serviceScopeFactory.CreateScope(); - try + if (_distributedLock.ObtainLock(LockingResourceName)) { - IAmACommandProcessor commandProcessor = scope.ServiceProvider.GetService(); + s_logger.LogInformation("Outbox Sweeper looking for unsent messages"); - var outBoxSweeper = new OutboxSweeper( - millisecondsSinceSent: _options.MinimumMessageAge, - commandProcessor: commandProcessor, - _options.BatchSize, - _options.UseBulk, - _options.Args); + var scope = _serviceScopeFactory.CreateScope(); + try + { + IAmACommandProcessor commandProcessor = scope.ServiceProvider.GetService(); - if (_options.UseBulk) - outBoxSweeper.SweepAsyncOutbox(); - else - outBoxSweeper.Sweep(); - } - catch (Exception e) - { - s_logger.LogError(e, "Error while sweeping the outbox."); - throw; + var outBoxSweeper = new OutboxSweeper( + millisecondsSinceSent: _options.MinimumMessageAge, + commandProcessor: commandProcessor, + _options.BatchSize, + _options.UseBulk, + _options.Args); + + if (_options.UseBulk) + outBoxSweeper.SweepAsyncOutbox(); + else + outBoxSweeper.Sweep(); + } + catch (Exception e) + { + s_logger.LogError(e, "Error while sweeping the outbox."); + throw; + } + finally + { + _distributedLock.ReleaseLock(LockingResourceName); + scope.Dispose(); + } } - finally + else { - scope.Dispose(); + s_logger.LogWarning("Outbox Sweeper is still running - abandoning attempt."); } s_logger.LogInformation("Outbox Sweeper sleeping"); diff --git a/src/Paramore.Brighter/IDistributedLock.cs b/src/Paramore.Brighter/IDistributedLock.cs new file mode 100644 index 0000000000..740e79cb4c --- /dev/null +++ b/src/Paramore.Brighter/IDistributedLock.cs @@ -0,0 +1,38 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Brighter +{ + public interface IDistributedLock + { + /// + /// Attempt to obtain a lock on a resource + /// + /// The name of the resource to Lock + /// The Cancellation Token + /// True if the lock was obtained + Task ObtainLockAsync(string resource, CancellationToken cancellationToken); + + /// + /// Attempt to obtain a lock on a resource + /// + /// The name of the resource to Lock + /// True if the lock was obtained + bool ObtainLock(string resource); + + /// + /// Release a lock + /// + /// + /// + /// Awaitable Task + Task ReleaseLockAsync(string resource, CancellationToken cancellationToken); + + /// + /// Release a lock + /// + /// + /// Awaitable Task + void ReleaseLock(string resource); + } +} diff --git a/src/Paramore.Brighter/InMemoryLock.cs b/src/Paramore.Brighter/InMemoryLock.cs new file mode 100644 index 0000000000..da98556898 --- /dev/null +++ b/src/Paramore.Brighter/InMemoryLock.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Brighter +{ + + public class InMemoryLock : IDistributedLock + { + private readonly Dictionary _semaphores = new Dictionary(); + + public async Task ObtainLockAsync(string resource, CancellationToken cancellationToken) + { + var normalisedResourceName = resource.ToLower(); + if (!_semaphores.ContainsKey(normalisedResourceName)) + _semaphores.Add(normalisedResourceName, new SemaphoreSlim(1, 1)); + + return await _semaphores[normalisedResourceName].WaitAsync(TimeSpan.Zero, cancellationToken); + } + + public bool ObtainLock(string resource) + { + var normalisedResourceName = resource.ToLower(); + if (!_semaphores.ContainsKey(normalisedResourceName)) + _semaphores.Add(normalisedResourceName, new SemaphoreSlim(1, 1)); + + return _semaphores[normalisedResourceName].Wait(TimeSpan.Zero); + } + + public Task ReleaseLockAsync(string resource, CancellationToken cancellationToken) + { + ReleaseLock(resource); + return Task.CompletedTask; + } + + public void ReleaseLock(string resource) + { + var normalisedResourceName = resource.ToLower(); + if (_semaphores.TryGetValue(normalisedResourceName, out SemaphoreSlim semaphore)) + semaphore.Release(); + } + } +} diff --git a/tests/Paramore.Brighter.Azure.Tests/AzureBlobLockingProviderTests.cs b/tests/Paramore.Brighter.Azure.Tests/AzureBlobLockingProviderTests.cs new file mode 100644 index 0000000000..dc67cd4c73 --- /dev/null +++ b/tests/Paramore.Brighter.Azure.Tests/AzureBlobLockingProviderTests.cs @@ -0,0 +1,45 @@ +using Azure.Identity; +using Paramore.Brighter.Locking.Azure; + +namespace Paramore.Brighter.Azure.Tests; + +public class AzureBlobLockingProviderTests +{ + private IDistributedLock _blobLocking; + + public AzureBlobLockingProviderTests() + { + var options = new AzureBlobLockingProviderOptions( + new Uri("https://brighterarchivertest.blob.core.windows.net/locking"), new AzureCliCredential()); + + _blobLocking = new AzureBlobLockingProvider(options); + } + + [Test] + public async Task GivenAnAzureBlobLockingProvider_WhenLockIsCalled_ItCanOnlyBeObtainedOnce() + { + var resourceName = $"TestLock-{Guid.NewGuid()}"; + + var firstLock = await _blobLocking.ObtainLockAsync(resourceName, CancellationToken.None); + var secondLock = await _blobLocking.ObtainLockAsync(resourceName, CancellationToken.None); + + Assert.That(firstLock, Is.True); + Assert.That(secondLock, Is.False, "A Lock should not be able to be acquired"); + } + + [Test] + public async Task GivenAnAzureBlobLockingProviderWithALockedBlob_WhenReleaseLockIsCalled_ItCanOnlyBeLockedAgain() + { + var resourceName = $"TestLock-{Guid.NewGuid()}"; + + var firstLock = await _blobLocking.ObtainLockAsync(resourceName, CancellationToken.None); + await _blobLocking.ReleaseLockAsync(resourceName, CancellationToken.None); + var secondLock = await _blobLocking.ObtainLockAsync(resourceName, CancellationToken.None); + var thirdLock = await _blobLocking.ObtainLockAsync(resourceName, CancellationToken.None); + + Assert.That(firstLock, Is.True); + Assert.That(secondLock, Is.True, "A Lock should be able to be acquired"); + Assert.That(thirdLock, Is.False, "A Lock should not be able to be acquired"); + } + +} diff --git a/tests/Paramore.Brighter.Azure.Tests/Paramore.Brighter.Azure.Tests.csproj b/tests/Paramore.Brighter.Azure.Tests/Paramore.Brighter.Azure.Tests.csproj index bb4ac00d87..4d21477229 100644 --- a/tests/Paramore.Brighter.Azure.Tests/Paramore.Brighter.Azure.Tests.csproj +++ b/tests/Paramore.Brighter.Azure.Tests/Paramore.Brighter.Azure.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/tests/Paramore.Brighter.Core.Tests/Locking/InMemoryLockingProviderTests.cs b/tests/Paramore.Brighter.Core.Tests/Locking/InMemoryLockingProviderTests.cs new file mode 100644 index 0000000000..22101ffc3a --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/Locking/InMemoryLockingProviderTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.Locking; + +public class InMemoryLockingProviderTests +{ + private IDistributedLock _locking = new InMemoryLock(); + + + [Fact] + public async Task GivenAnInMemoryLockingProvider_WhenLockIsCalled_ItCanOnlyBeObtainedOnce() + { + var resourceName = $"TestLock-{Guid.NewGuid()}"; + + var firstLock = await _locking.ObtainLockAsync(resourceName, CancellationToken.None); + var secondLock = await _locking.ObtainLockAsync(resourceName, CancellationToken.None); + + Assert.True(firstLock); + Assert.False(secondLock, "A Lock should not be able to be acquired"); + } + + [Fact] + public async Task GivenAnAzureBlobLockingProviderWithALockedBlob_WhenReleaseLockIsCalled_ItCanOnlyBeLockedAgain() + { + var resourceName = $"TestLock-{Guid.NewGuid()}"; + + var firstLock = await _locking.ObtainLockAsync(resourceName, CancellationToken.None); + await _locking.ReleaseLockAsync(resourceName, CancellationToken.None); + var secondLock = await _locking.ObtainLockAsync(resourceName, CancellationToken.None); + var thirdLock = await _locking.ObtainLockAsync(resourceName, CancellationToken.None); + + Assert.True(firstLock); + Assert.True(secondLock, "A Lock should be able to be acquired"); + Assert.False(thirdLock, "A Lock should not be able to be acquired"); + } +}