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() {