From 042c96d170756eb5164e199dee1d86824e8314c6 Mon Sep 17 00:00:00 2001 From: Paul Reardon Date: Tue, 27 Aug 2024 14:32:36 +0100 Subject: [PATCH] Add MsSql Locking Provider (#3283) Feature #3175 --- Brighter.sln | 14 ++ Directory.Packages.props | 40 +++--- .../MsSqlLockingProvider.cs | 134 ++++++++++++++++++ .../MsSqlLockingQueries.cs | 18 +++ .../Paramore.Brighter.Locking.MsSql.csproj | 21 +++ .../MsSqlLockingProviderTests.cs | 100 +++++++++++++ .../MsSqlTestHelper.cs | 2 + .../Paramore.Brighter.MSSQL.Tests.csproj | 1 + 8 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 src/Paramore.Brighter.Locking.MsSql/MsSqlLockingProvider.cs create mode 100644 src/Paramore.Brighter.Locking.MsSql/MsSqlLockingQueries.cs create mode 100644 src/Paramore.Brighter.Locking.MsSql/Paramore.Brighter.Locking.MsSql.csproj create mode 100644 tests/Paramore.Brighter.MSSQL.Tests/LockingProvider/MsSqlLockingProviderTests.cs diff --git a/Brighter.sln b/Brighter.sln index ccddc7534c..dfd0b8239f 100644 --- a/Brighter.sln +++ b/Brighter.sln @@ -349,6 +349,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Paramore.Brighter.Locking.A EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Paramore.Brighter.Locking.DynamoDB", "src\Paramore.Brighter.Locking.DynamoDB\Paramore.Brighter.Locking.DynamoDB.csproj", "{CBF99394-E332-439B-8632-ABDE06F6E343}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Paramore.Brighter.Locking.MsSql", "src\Paramore.Brighter.Locking.MsSql\Paramore.Brighter.Locking.MsSql.csproj", "{9EB2566B-1115-4E32-916B-222A74FA20B4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1991,6 +1993,18 @@ Global {CBF99394-E332-439B-8632-ABDE06F6E343}.Release|Mixed Platforms.Build.0 = Release|Any CPU {CBF99394-E332-439B-8632-ABDE06F6E343}.Release|x86.ActiveCfg = Release|Any CPU {CBF99394-E332-439B-8632-ABDE06F6E343}.Release|x86.Build.0 = Release|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Debug|x86.Build.0 = Debug|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Release|Any CPU.Build.0 = Release|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Release|x86.ActiveCfg = Release|Any CPU + {9EB2566B-1115-4E32-916B-222A74FA20B4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Directory.Packages.props b/Directory.Packages.props index faf59ba2ec..c20f65a739 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,15 +4,15 @@ false - - - - - - - - - + + + + + + + + + @@ -31,13 +31,13 @@ - - + + - + @@ -76,9 +76,9 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -111,12 +111,12 @@ - - - - - - + + + + + + diff --git a/src/Paramore.Brighter.Locking.MsSql/MsSqlLockingProvider.cs b/src/Paramore.Brighter.Locking.MsSql/MsSqlLockingProvider.cs new file mode 100644 index 0000000000..0d4fe40eda --- /dev/null +++ b/src/Paramore.Brighter.Locking.MsSql/MsSqlLockingProvider.cs @@ -0,0 +1,134 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2021 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Collections.Concurrent; +using System.Data; +using System.Data.Common; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using Paramore.Brighter.Logging; +using Paramore.Brighter.MsSql; + +namespace Paramore.Brighter.Locking.MsSql; + +/// +/// The Microsoft Sql Server Locking Provider +/// +/// The Sql Server connection Provider +public class MsSqlLockingProvider(IMsSqlConnectionProvider connectionProvider) : IDistributedLock, IAsyncDisposable +{ + private readonly ConcurrentDictionary _connections = new(); + + private readonly ILogger _logger = ApplicationLogging.CreateLogger(); + /// + /// Attempt to obtain a lock on a resource + /// + /// The name of the resource to Lock + /// The Cancellation Token + /// The id of the lock that has been acquired or null if no lock was able to be acquired + public async Task ObtainLockAsync(string resource, CancellationToken cancellationToken) + { + if (_connections.ContainsKey(resource)) + { + return null; + } + + var connection = await connectionProvider.GetConnectionAsync(cancellationToken); + if (connection.State != ConnectionState.Open) + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = MsSqlLockingQueries.ObtainLockQuery; + command.Parameters.Add(new SqlParameter("@Resource", SqlDbType.NVarChar, 255)); + command.Parameters["@Resource"].Value = resource; + command.Parameters.Add(new SqlParameter("@LockTimeout", SqlDbType.Int)); + command.Parameters["@LockTimeout"].Value = 0; + + var result = (await command.ExecuteScalarAsync(cancellationToken)) ?? -999; + + var resultCode = (int)result; + + _logger.LogInformation("Attempt to obtain lock returned: {MsSqlLockResult}", GetLockStatusCode(resultCode)); + + if (resultCode < 0) + return null; + + _connections.TryAdd(resource, connection); + + return resource; + } + + /// + /// Release a lock + /// + /// The name of the resource to Lock + /// The lock Id that was provided when the lock was obtained + /// + /// Awaitable Task + public async Task ReleaseLockAsync(string resource, string lockId, CancellationToken cancellationToken) + { + if (!_connections.TryRemove(resource, out var connection)) + { + return; + } + + await using var command = connection.CreateCommand(); + command.CommandText = MsSqlLockingQueries.ReleaseLockQuery; + command.Parameters.Add(new SqlParameter("@Resource", SqlDbType.NVarChar, 255)); + command.Parameters["@Resource"].Value = resource; + await command.ExecuteNonQueryAsync(cancellationToken); + + await connection.CloseAsync(); + await connection.DisposeAsync(); + } + + /// + /// Convert Status code to messages + /// Doc: https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql?view=sql-server-ver16#return-code-values + /// + /// Status Code + /// Status Message + private string GetLockStatusCode(int code) + => code switch + { + 0 => "The lock was successfully granted synchronously.", + 1 => "The lock was granted successfully after waiting for other incompatible locks to be released.", + -1 => "The lock request timed out.", + -2 => "The lock request was canceled.", + -3 => "The lock request was chosen as a deadlock victim.", + _ => "Indicates a parameter validation or other call error." + }; + + /// + /// Dispose Locking Provider + /// + public async ValueTask DisposeAsync() + { + foreach (var connection in _connections) + { + await connection.Value.DisposeAsync(); + } + } +} diff --git a/src/Paramore.Brighter.Locking.MsSql/MsSqlLockingQueries.cs b/src/Paramore.Brighter.Locking.MsSql/MsSqlLockingQueries.cs new file mode 100644 index 0000000000..2bfac270c6 --- /dev/null +++ b/src/Paramore.Brighter.Locking.MsSql/MsSqlLockingQueries.cs @@ -0,0 +1,18 @@ +namespace Paramore.Brighter.Locking.MsSql; + +public static class MsSqlLockingQueries +{ + public const string ObtainLockQuery = "declare @result int; " + + "Exec @result = sp_getapplock " + + "@DbPrincipal = 'dbo' " + + ",@Resource = @Resource" + + ",@LockMode = 'Exclusive'" + + ",@LockTimeout = @LockTimeout" + + ",@LockOwner = 'Session'; " + + "Select @result"; + + public const string ReleaseLockQuery = "EXEC sp_releaseapplock " + + "@Resource = @Resource " + + ",@DbPrincipal = 'dbo' " + + ",@LockOwner = 'Session';"; +} diff --git a/src/Paramore.Brighter.Locking.MsSql/Paramore.Brighter.Locking.MsSql.csproj b/src/Paramore.Brighter.Locking.MsSql/Paramore.Brighter.Locking.MsSql.csproj new file mode 100644 index 0000000000..bbd07439b5 --- /dev/null +++ b/src/Paramore.Brighter.Locking.MsSql/Paramore.Brighter.Locking.MsSql.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + true + Paul Reardon + A Locking Provider for Microsoft SQL Server + + + + + + + + + + + + diff --git a/tests/Paramore.Brighter.MSSQL.Tests/LockingProvider/MsSqlLockingProviderTests.cs b/tests/Paramore.Brighter.MSSQL.Tests/LockingProvider/MsSqlLockingProviderTests.cs new file mode 100644 index 0000000000..4f15eca27c --- /dev/null +++ b/tests/Paramore.Brighter.MSSQL.Tests/LockingProvider/MsSqlLockingProviderTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging.Abstractions; +using Paramore.Brighter.Locking.MsSql; +using Paramore.Brighter.MsSql; +using Xunit; + +namespace Paramore.Brighter.MSSQL.Tests.LockingProvider; + +[Trait("Category", "MSSQL")] +public class MsSqlLockingProviderTests +{ + private readonly MsSqlTestHelper _msSqlTestHelper; + + public MsSqlLockingProviderTests() + { + _msSqlTestHelper = new MsSqlTestHelper(); + _msSqlTestHelper.SetupMessageDb(); + } + + + [Fact] + public async Task GivenAMsSqlLockingProvider_WhenLockIsCalled_LockCanBeObtainedAndThenReleased() + { + var provider = new MsSqlLockingProvider(_msSqlTestHelper.ConnectionProvider); + var resource = "Sweeper"; + + var result = await provider.ObtainLockAsync(resource, CancellationToken.None); + + Assert.NotEmpty(result); + Assert.Equal(resource, result); + + await provider.ReleaseLockAsync(resource, result, CancellationToken.None); + } + + [Fact] + public async Task GivenTwoLockingProviders_WhenLockIsCalledOnBoth_OneFailsUntilTheFirstLockIsReleased() + { + var provider1 = new MsSqlLockingProvider(_msSqlTestHelper.ConnectionProvider); + var provider2 = new MsSqlLockingProvider(_msSqlTestHelper.ConnectionProvider); + var resource = "Sweeper"; + + var firstLock = await provider1.ObtainLockAsync(resource, CancellationToken.None); + var secondLock = await provider2.ObtainLockAsync(resource, CancellationToken.None); + + Assert.NotEmpty(firstLock); + Assert.Null(secondLock); + + await provider1.ReleaseLockAsync(resource, firstLock, CancellationToken.None); + var secondLockAttemptTwo = await provider2.ObtainLockAsync(resource, CancellationToken.None); + + Assert.NotEmpty(secondLockAttemptTwo); + } + + [Fact] + public async Task GivenAnExistingLock_WhenConnectionDies_LockIsReleased() + { + var resource = Guid.NewGuid().ToString(); + var connection = await ObtainLockForManualDisposal(resource); + + var provider1 = new MsSqlLockingProvider(_msSqlTestHelper.ConnectionProvider); + + var lockAttempt = await provider1.ObtainLockAsync(resource, CancellationToken.None); + + // Ensure Lock was not obtained + Assert.Null(lockAttempt); + + await connection.DisposeAsync(); + + var lockAttemptTwo = await provider1.ObtainLockAsync(resource, CancellationToken.None); + + // Ensure Lock was Obtained + Assert.False(string.IsNullOrEmpty(lockAttemptTwo)); + } + + private async Task ObtainLockForManualDisposal(string resource) + { + var connectionProvider = _msSqlTestHelper.ConnectionProvider; + var connection = await connectionProvider.GetConnectionAsync(CancellationToken.None); + await connection.OpenAsync(); + var command = connection.CreateCommand(); + command.CommandText = MsSqlLockingQueries.ObtainLockQuery; + command.Parameters.Add(new SqlParameter("@Resource", SqlDbType.NVarChar, 255)); + command.Parameters["@Resource"].Value = resource; + command.Parameters.Add(new SqlParameter("@LockTimeout", SqlDbType.Int)); + command.Parameters["@LockTimeout"].Value = 0; + + var respone = await command.ExecuteScalarAsync(CancellationToken.None); + + //Assert Lock was successful + int.TryParse(respone.ToString(), out var responseCode); + Assert.True(responseCode >= 0); + + return connection; + } +} diff --git a/tests/Paramore.Brighter.MSSQL.Tests/MsSqlTestHelper.cs b/tests/Paramore.Brighter.MSSQL.Tests/MsSqlTestHelper.cs index af22784e66..94eee51398 100644 --- a/tests/Paramore.Brighter.MSSQL.Tests/MsSqlTestHelper.cs +++ b/tests/Paramore.Brighter.MSSQL.Tests/MsSqlTestHelper.cs @@ -13,6 +13,8 @@ public class MsSqlTestHelper private SqlSettings _sqlSettings; private IMsSqlConnectionProvider _connectionProvider; private IMsSqlConnectionProvider _masterConnectionProvider; + + public IMsSqlConnectionProvider ConnectionProvider { get => _connectionProvider; } private const string _queueDDL = @"CREATE TABLE [dbo].[{0}]( [Id][bigint] IDENTITY(1, 1) NOT NULL, diff --git a/tests/Paramore.Brighter.MSSQL.Tests/Paramore.Brighter.MSSQL.Tests.csproj b/tests/Paramore.Brighter.MSSQL.Tests/Paramore.Brighter.MSSQL.Tests.csproj index 53fc3f1d1a..6fb6f926f5 100644 --- a/tests/Paramore.Brighter.MSSQL.Tests/Paramore.Brighter.MSSQL.Tests.csproj +++ b/tests/Paramore.Brighter.MSSQL.Tests/Paramore.Brighter.MSSQL.Tests.csproj @@ -24,6 +24,7 @@ +