diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianRetention.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianRetention.cs
index c8bc8e00..24816415 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianRetention.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianRetention.cs
@@ -42,6 +42,9 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
/// Test seam for .
internal Action ClearPool { get; set; } = SqlConnection.ClearPool;
+ /// Test seam: the release statement. Tests point it at a failing statement to exercise the pool-clear fallback.
+ internal string ReleaseSql { get; set; } = "EXEC sp_releaseapplock @Resource = @res, @LockOwner = 'Session';";
+
public async Task> PurgeAsync(CancellationToken ct)
{
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
@@ -137,8 +140,7 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
{
try
{
- await using var cmd = _conn.CreateCommand(conn,
- "EXEC sp_releaseapplock @Resource = @res, @LockOwner = 'Session';");
+ await using var cmd = _conn.CreateCommand(conn, ReleaseSql);
cmd.Parameters.Add(new SqlParameter("@res", SqlDbType.NVarChar, 255) { Value = LockResource });
await cmd.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RetentionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RetentionTests.cs
index 046869d6..50870b4a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RetentionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/RetentionTests.cs
@@ -178,6 +178,80 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
(await LockIsFreeAsync(s)).ShouldBeTrue();
}
+ [Fact]
+ public async Task Cancelling_mid_command_while_the_delete_is_blocked_still_releases_the_applock()
+ {
+ fx.SkipIfUnavailable();
+ var (c, b, _, s) = Make();
+ await SeedAsync(b, s);
+
+ // A second session holds an exclusive table lock, so the purger's first DELETE blocks server-side.
+ await using var holder = new SqlConnection(fx.ConnectionString);
+ await holder.OpenAsync();
+ await using var tx = (SqlTransaction)await holder.BeginTransactionAsync();
+ await using (var cmd = holder.CreateCommand())
+ {
+ cmd.Transaction = tx;
+ cmd.CommandText = $"SELECT COUNT(*) FROM [{s}].Sample WITH (TABLOCKX, HOLDLOCK)";
+ await cmd.ExecuteScalarAsync();
+ }
+
+ using var cts = new CancellationTokenSource();
+ var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now));
+ var purge = purger.PurgeAsync(cts.Token);
+ await Task.Delay(500);
+ purge.IsCompleted.ShouldBeFalse("the DELETE must be blocked behind the holder's table lock");
+ cts.Cancel();
+
+ // SqlClient may surface the attention as a SqlException rather than OperationCanceledException.
+ await Should.ThrowAsync(() => purge);
+
+ await tx.RollbackAsync();
+ (await LockIsFreeAsync(s)).ShouldBeTrue();
+ (await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(21);
+ }
+
+ [Fact]
+ public async Task A_failing_release_falls_back_to_clearing_the_pool_so_the_applock_is_freed()
+ {
+ fx.SkipIfUnavailable();
+ var (c, b, _, s) = Make();
+ await SeedAsync(b, s);
+ var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now))
+ {
+ ReleaseSql = "EXEC sp_this_procedure_does_not_exist;",
+ };
+
+ await purger.PurgeAsync(CancellationToken.None);
+
+ (await LockIsFreeAsync(s)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Falsification_control_a_failing_release_with_a_no_op_ClearPool_leaves_the_applock_held()
+ {
+ fx.SkipIfUnavailable();
+ var (c, b, _, s) = Make();
+ await SeedAsync(b, s);
+ var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now))
+ {
+ ReleaseSql = "EXEC sp_this_procedure_does_not_exist;",
+ ClearPool = _ => { },
+ };
+
+ await purger.PurgeAsync(CancellationToken.None);
+
+ try
+ {
+ // The pooled session still owns the Session-scoped lock: proves the fallback test above is not vacuous.
+ (await LockIsFreeAsync(s)).ShouldBeFalse();
+ }
+ finally
+ {
+ SqlConnection.ClearAllPools(); // drop the leaked session so the lock does not outlive the test
+ }
+ }
+
[Fact]
public async Task A_throwing_purge_releases_the_applock()
{