From d485729caa970cfc7e6c1141b86849e429d084ef Mon Sep 17 00:00:00 2001 From: Zlatko Zajec Date: Wed, 3 Sep 2025 11:49:12 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8feat:=20Added=20new=20method=20Try?= =?UTF-8?q?GetAcquireLocks=20and=20override=20for=20ReleaseAppLock=20metho?= =?UTF-8?q?d=20to=20work=20with=20lists.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SqlServer/AcquireLocksTests.cs | 112 ++++++++++++++++++ .../Simpleverse.Repository.Db.csproj | 6 +- .../SqlServer/SqlConnectionExtensions.cs | 61 ++++++++++ 3 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs diff --git a/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs b/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs new file mode 100644 index 0000000..da5838f --- /dev/null +++ b/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Data.SqlClient; +using Simpleverse.Repository.Db.SqlServer; +using StackExchange.Profiling.Data; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Simpleverse.Repository.Db.Test.SqlServer +{ + [Collection("SqlServerCollection")] + public class AcquireLocksTests : DatabaseTestFixture + { + public AcquireLocksTests(DatabaseFixture fixture, ITestOutputHelper output) + : base(fixture, output) + { + } + + [Fact] + public async Task TryGetAcquireLocks_AllLocksAcquired_ReturnsTrue() + { + using (var connection = _fixture.GetProfiledConnection()) + { + connection.Open(); + var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; + using (var transaction = sqlConnection.BeginTransaction()) + { + var keys = new List { 101, 102, 103 }; + + // act + var result = await sqlConnection.TryGetAcquireLocks(keys, transaction: transaction); + + // assert + Assert.True(result); + + transaction.Commit(); + } + } + } + + [Fact] + public async Task TryGetAcquireLocks_ParallelThreads_ContentionTest() + { + var keys = new List { 201, 202, 203 }; + + async Task TryAcquireLocksAsync() + { + using var connection = _fixture.GetProfiledConnection(); + connection.Open(); + var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; + using var transaction = sqlConnection.BeginTransaction(); + var result = await sqlConnection.TryGetAcquireLocks(keys, transaction: transaction, lockTimeout: TimeSpan.FromMilliseconds(500)); + if (result) + transaction.Commit(); + else + transaction.Rollback(); + return result; + } + + // Run two tasks in parallel + var task1 = Task.Run(TryAcquireLocksAsync); + var task2 = Task.Run(TryAcquireLocksAsync); + + var results = await Task.WhenAll(task1, task2); + + // Only one should succeed in acquiring all locks + Assert.Equal(2, results.Count(r => r)); + } + + [Fact] + public async Task TryGetAcquireLocks_LocksNotAcquired_ReturnsFalse() + { + var keys = new List { 301, 302, 303 }; + + // Simulate another session holding the locks + using var connection1 = _fixture.GetProfiledConnection(); + connection1.Open(); + var sqlConnection1 = (SqlConnection)((ProfiledDbConnection)connection1).WrappedConnection; + using var transaction1 = sqlConnection1.BeginTransaction(); + await sqlConnection1.TryGetAcquireLocks(keys, transaction: transaction1, lockTimeout: TimeSpan.FromSeconds(10)); + + // Try to acquire the same locks with a short timeout + using var connection2 = _fixture.GetProfiledConnection(); + connection2.Open(); + var sqlConnection2 = (SqlConnection)((ProfiledDbConnection)connection2).WrappedConnection; + using var transaction2 = sqlConnection2.BeginTransaction(); + var result = await sqlConnection2.TryGetAcquireLocks(keys, transaction: transaction2, lockTimeout: TimeSpan.FromMilliseconds(100)); + Assert.False(result); + + transaction1.Rollback(); + transaction2.Rollback(); + } + + [Fact] + public async Task TryGetAcquireLocks_NullKeys_ThrowsArgumentException() + { + using var connection = _fixture.GetProfiledConnection(); + connection.Open(); + var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; + using var transaction = sqlConnection.BeginTransaction(); + + await Assert.ThrowsAsync(async () => + { + await sqlConnection.TryGetAcquireLocks(null, transaction: transaction); + }); + + transaction.Rollback(); + } + } +} diff --git a/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj b/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj index 64100be..aedbe08 100644 --- a/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj +++ b/src/Simpleverse.Repository.Db/Simpleverse.Repository.Db.csproj @@ -13,10 +13,10 @@ true Dapper, Bulk, Merge, Upsert, Delete, Insert, Update, Repository LICENSE - 2.1.30 + 2.1.31 High performance operation for MS SQL Server built for Dapper ORM. Including bulk operations Insert, Update, Delete, Get as well as Upsert both single and bulk. - 2.1.30.0 - 2.1.30.0 + 2.1.31.0 + 2.1.31.0 https://github.com/lukaferlez/Simpleverse.Repository README.md true diff --git a/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs b/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs index 450afd1..5961273 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Data; using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace Simpleverse.Repository.Db.SqlServer @@ -88,6 +89,66 @@ select @result return result == 0; } + public static async Task ReleaseAppLockAsync(this SqlConnection connection, IEnumerable keys, IDbTransaction transaction = null) + { + bool allReleased = true; + + foreach (var key in keys) + { + var released = await connection.ReleaseAppLockAsync(key, transaction); + if (!released) + allReleased = false; + } + + return allReleased; + } + + public static async Task TryGetAcquireLocks(this SqlConnection connection, IEnumerable keys, int retryTimeout = 100, int numberOfRetries = 3, IDbTransaction transaction = null, TimeSpan? lockTimeout = null) + { + if (keys == null) + throw new ArgumentException("Keys collection must not be null or empty.", nameof(keys)); + if (numberOfRetries < 1) + throw new ArgumentOutOfRangeException(nameof(numberOfRetries), "Number of retries must be at least 1."); + if (retryTimeout < 0) + throw new ArgumentOutOfRangeException(nameof(retryTimeout), "Retry timeout must be non-negative."); + + var acquiredLocks = new List(); + + for (int retry = 0; retry < numberOfRetries; retry++) + { + bool allLocked = true; + acquiredLocks.Clear(); + + foreach (var key in keys) + { + var lockName = $"Sql_lock_{key}"; + var locked = await connection.GetAppLockAsync(lockName, transaction, lockTimeout); + if (locked) + { + acquiredLocks.Add(lockName); + } + else + { + allLocked = false; + break; + } + } + + if (!allLocked) + { + await connection.ReleaseAppLockAsync(acquiredLocks, transaction); + + await Task.Delay(retryTimeout); + } + else + { + return true; + } + } + + return false; + } + public static Task ExecuteWithAppLockAsync( this SqlConnection conn, string resourceIdentifier, From 160f5d5f0620ced22287c080ede84d0fa4106d06 Mon Sep 17 00:00:00 2001 From: Zlatko Zajec Date: Wed, 3 Sep 2025 13:47:10 +0200 Subject: [PATCH 2/3] Comment changes. --- .../SqlServer/AcquireLocksTests.cs | 79 +++++++++++++++---- .../SqlServer/SqlConnectionExtensions.cs | 40 +++++----- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs b/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs index da5838f..fda2cb0 100644 --- a/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs +++ b/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs @@ -30,7 +30,7 @@ public async Task TryGetAcquireLocks_AllLocksAcquired_ReturnsTrue() var keys = new List { 101, 102, 103 }; // act - var result = await sqlConnection.TryGetAcquireLocks(keys, transaction: transaction); + var result = await sqlConnection.TryGetAppLockAsync(keys, transaction: transaction); // assert Assert.True(result); @@ -51,7 +51,7 @@ async Task TryAcquireLocksAsync() connection.Open(); var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; using var transaction = sqlConnection.BeginTransaction(); - var result = await sqlConnection.TryGetAcquireLocks(keys, transaction: transaction, lockTimeout: TimeSpan.FromMilliseconds(500)); + var result = await sqlConnection.TryGetAppLockAsync(keys, transaction: transaction, lockTimeout: TimeSpan.FromMilliseconds(500)); if (result) transaction.Commit(); else @@ -70,43 +70,92 @@ async Task TryAcquireLocksAsync() } [Fact] - public async Task TryGetAcquireLocks_LocksNotAcquired_ReturnsFalse() + public async Task TryGetAcquireLocks_NullKeys_ThrowsArgumentException() + { + using var connection = _fixture.GetProfiledConnection(); + connection.Open(); + var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; + using var transaction = sqlConnection.BeginTransaction(); + + await Assert.ThrowsAsync(async () => + { + await sqlConnection.TryGetAppLockAsync(null, transaction: transaction); + }); + + transaction.Rollback(); + } + + [Fact] + public async Task TryGetAcquireLocks_EmptyKeys() { - var keys = new List { 301, 302, 303 }; + using var connection = _fixture.GetProfiledConnection(); + connection.Open(); + var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; + using var transaction = sqlConnection.BeginTransaction(); + + try + { + await sqlConnection.TryGetAppLockAsync(new List { }, transaction: transaction); + } + catch (ArgumentException ex) when (ex.Message.Contains("Keys collection must not be null or empty.", StringComparison.OrdinalIgnoreCase)) + { + transaction.Rollback(); + return; + } + } + + [Fact] + public async Task TryGetAcquireLocks_DuplicateKeys_ReturnsTrue() + { + using var connection = _fixture.GetProfiledConnection(); + connection.Open(); + var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; + using var transaction = sqlConnection.BeginTransaction(); + + var keys = new List { 301, 301, 302 }; + var result = await sqlConnection.TryGetAppLockAsync(keys, transaction: transaction); + + Assert.True(result); + + transaction.Commit(); + } + + [Fact] + public async Task TryGetAcquireLocks_LockTimeout_ReturnsFalse() + { + var keys = new List { 401, 402 }; - // Simulate another session holding the locks using var connection1 = _fixture.GetProfiledConnection(); connection1.Open(); var sqlConnection1 = (SqlConnection)((ProfiledDbConnection)connection1).WrappedConnection; using var transaction1 = sqlConnection1.BeginTransaction(); - await sqlConnection1.TryGetAcquireLocks(keys, transaction: transaction1, lockTimeout: TimeSpan.FromSeconds(10)); + await sqlConnection1.TryGetAppLockAsync(keys, transaction: transaction1); - // Try to acquire the same locks with a short timeout using var connection2 = _fixture.GetProfiledConnection(); connection2.Open(); var sqlConnection2 = (SqlConnection)((ProfiledDbConnection)connection2).WrappedConnection; using var transaction2 = sqlConnection2.BeginTransaction(); - var result = await sqlConnection2.TryGetAcquireLocks(keys, transaction: transaction2, lockTimeout: TimeSpan.FromMilliseconds(100)); - Assert.False(result); + + await Assert.ThrowsAsync(async () => + { + var result = await sqlConnection2.TryGetAppLockAsync(keys, transaction: transaction2, lockTimeout: TimeSpan.FromMilliseconds(100)); + }); transaction1.Rollback(); transaction2.Rollback(); } [Fact] - public async Task TryGetAcquireLocks_NullKeys_ThrowsArgumentException() + public async Task TryGetAcquireLocks_NullTransaction_ThrowsArgumentNullException() { using var connection = _fixture.GetProfiledConnection(); connection.Open(); var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; - using var transaction = sqlConnection.BeginTransaction(); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { - await sqlConnection.TryGetAcquireLocks(null, transaction: transaction); + await sqlConnection.TryGetAppLockAsync(new List { 501 }, transaction: null); }); - - transaction.Rollback(); } } } diff --git a/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs b/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs index 5961273..d6ea4a6 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Data; +using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -103,50 +104,45 @@ public static async Task ReleaseAppLockAsync(this SqlConnection connection return allReleased; } - public static async Task TryGetAcquireLocks(this SqlConnection connection, IEnumerable keys, int retryTimeout = 100, int numberOfRetries = 3, IDbTransaction transaction = null, TimeSpan? lockTimeout = null) + public static async Task TryGetAppLockAsync(this SqlConnection connection, IEnumerable keys, int retryTimeout = 100, int numberOfRetries = 3, IDbTransaction transaction = null, TimeSpan? lockTimeout = null) { - if (keys == null) + if (keys == null || !keys.Any()) throw new ArgumentException("Keys collection must not be null or empty.", nameof(keys)); if (numberOfRetries < 1) throw new ArgumentOutOfRangeException(nameof(numberOfRetries), "Number of retries must be at least 1."); if (retryTimeout < 0) throw new ArgumentOutOfRangeException(nameof(retryTimeout), "Retry timeout must be non-negative."); - var acquiredLocks = new List(); + bool allLocked = true; for (int retry = 0; retry < numberOfRetries; retry++) { - bool allLocked = true; - acquiredLocks.Clear(); + int lastAttemptedIndex = -1; + allLocked = true; foreach (var key in keys) { - var lockName = $"Sql_lock_{key}"; + var lockName = $"{key}"; var locked = await connection.GetAppLockAsync(lockName, transaction, lockTimeout); - if (locked) - { - acquiredLocks.Add(lockName); - } - else + lastAttemptedIndex = retry; + + if (!locked) { allLocked = false; - break; + break; } } - if (!allLocked) - { - await connection.ReleaseAppLockAsync(acquiredLocks, transaction); + if (allLocked) + break; - await Task.Delay(retryTimeout); - } - else - { - return true; - } + var keyToRelease = string.Join(",", keys.Take(lastAttemptedIndex + 1)); + + await connection.ReleaseAppLockAsync(keyToRelease, transaction); + await Task.Delay(retryTimeout); } - return false; + return allLocked; } public static Task ExecuteWithAppLockAsync( From 09f04a905347719e83d23bf96a29d669ee2dbedf Mon Sep 17 00:00:00 2001 From: Zlatko Zajec Date: Wed, 3 Sep 2025 14:17:07 +0200 Subject: [PATCH 3/3] Comment fix --- .../SqlServer/AcquireLocksTests.cs | 29 +++++-------------- .../SqlServer/SqlConnectionExtensions.cs | 14 ++++----- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs b/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs index fda2cb0..bda705f 100644 --- a/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs +++ b/src/Simpleverse.Repository.Db.Test/SqlServer/AcquireLocksTests.cs @@ -27,7 +27,7 @@ public async Task TryGetAcquireLocks_AllLocksAcquired_ReturnsTrue() var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; using (var transaction = sqlConnection.BeginTransaction()) { - var keys = new List { 101, 102, 103 }; + var keys = new List { "101", "102", "103" }; // act var result = await sqlConnection.TryGetAppLockAsync(keys, transaction: transaction); @@ -43,7 +43,7 @@ public async Task TryGetAcquireLocks_AllLocksAcquired_ReturnsTrue() [Fact] public async Task TryGetAcquireLocks_ParallelThreads_ContentionTest() { - var keys = new List { 201, 202, 203 }; + var keys = new List { "201", "202", "203" }; async Task TryAcquireLocksAsync() { @@ -95,7 +95,7 @@ public async Task TryGetAcquireLocks_EmptyKeys() try { - await sqlConnection.TryGetAppLockAsync(new List { }, transaction: transaction); + await sqlConnection.TryGetAppLockAsync(new List { }, transaction: transaction); } catch (ArgumentException ex) when (ex.Message.Contains("Keys collection must not be null or empty.", StringComparison.OrdinalIgnoreCase)) { @@ -112,7 +112,7 @@ public async Task TryGetAcquireLocks_DuplicateKeys_ReturnsTrue() var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; using var transaction = sqlConnection.BeginTransaction(); - var keys = new List { 301, 301, 302 }; + var keys = new List { "301", "301", "302" }; var result = await sqlConnection.TryGetAppLockAsync(keys, transaction: transaction); Assert.True(result); @@ -123,7 +123,7 @@ public async Task TryGetAcquireLocks_DuplicateKeys_ReturnsTrue() [Fact] public async Task TryGetAcquireLocks_LockTimeout_ReturnsFalse() { - var keys = new List { 401, 402 }; + var keys = new List { "401", "402" }; using var connection1 = _fixture.GetProfiledConnection(); connection1.Open(); @@ -135,27 +135,12 @@ public async Task TryGetAcquireLocks_LockTimeout_ReturnsFalse() connection2.Open(); var sqlConnection2 = (SqlConnection)((ProfiledDbConnection)connection2).WrappedConnection; using var transaction2 = sqlConnection2.BeginTransaction(); + var result = await sqlConnection2.TryGetAppLockAsync(keys, transaction: transaction2, lockTimeout: TimeSpan.FromMilliseconds(100)); - await Assert.ThrowsAsync(async () => - { - var result = await sqlConnection2.TryGetAppLockAsync(keys, transaction: transaction2, lockTimeout: TimeSpan.FromMilliseconds(100)); - }); + Assert.False(result); transaction1.Rollback(); transaction2.Rollback(); } - - [Fact] - public async Task TryGetAcquireLocks_NullTransaction_ThrowsArgumentNullException() - { - using var connection = _fixture.GetProfiledConnection(); - connection.Open(); - var sqlConnection = (SqlConnection)((ProfiledDbConnection)connection).WrappedConnection; - - await Assert.ThrowsAsync(async () => - { - await sqlConnection.TryGetAppLockAsync(new List { 501 }, transaction: null); - }); - } } } diff --git a/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs b/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs index d6ea4a6..3a53d4d 100644 --- a/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs +++ b/src/Simpleverse.Repository.Db/SqlServer/SqlConnectionExtensions.cs @@ -104,7 +104,7 @@ public static async Task ReleaseAppLockAsync(this SqlConnection connection return allReleased; } - public static async Task TryGetAppLockAsync(this SqlConnection connection, IEnumerable keys, int retryTimeout = 100, int numberOfRetries = 3, IDbTransaction transaction = null, TimeSpan? lockTimeout = null) + public static async Task TryGetAppLockAsync(this SqlConnection connection, IEnumerable keys, int retryTimeout = 100, int numberOfRetries = 3, IDbTransaction transaction = null, TimeSpan? lockTimeout = null) { if (keys == null || !keys.Any()) throw new ArgumentException("Keys collection must not be null or empty.", nameof(keys)); @@ -122,23 +122,21 @@ public static async Task TryGetAppLockAsync(this SqlConnection connection, foreach (var key in keys) { - var lockName = $"{key}"; - var locked = await connection.GetAppLockAsync(lockName, transaction, lockTimeout); - lastAttemptedIndex = retry; - + var locked = await connection.GetAppLockAsync(key, transaction, lockTimeout); + if (!locked) { allLocked = false; break; } + + lastAttemptedIndex++; } if (allLocked) break; - var keyToRelease = string.Join(",", keys.Take(lastAttemptedIndex + 1)); - - await connection.ReleaseAppLockAsync(keyToRelease, transaction); + await connection.ReleaseAppLockAsync(keys.Take(lastAttemptedIndex + 1), transaction); await Task.Delay(retryTimeout); }