test(historian): #678.1 retention mid-command cancel + ClearPool fallback with falsification control

This commit is contained in:
Joseph Doherty
2026-10-11 10:42:10 -04:00
parent 825393299e
commit f41df6710c
2 changed files with 78 additions and 2 deletions
@@ -42,6 +42,9 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
/// <summary>Test seam for <see cref="SqlConnection.ClearPool"/>.</summary>
internal Action<SqlConnection> ClearPool { get; set; } = SqlConnection.ClearPool;
/// <summary>Test seam: the release statement. Tests point it at a failing statement to exercise the pool-clear fallback.</summary>
internal string ReleaseSql { get; set; } = "EXEC sp_releaseapplock @Resource = @res, @LockOwner = 'Session';";
public async Task<IReadOnlyList<RetentionPurgeResult>> 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);
}
@@ -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<Exception>(() => 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()
{