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 @@
+