test(historian): #678.1 retention mid-command cancel + ClearPool fallback with falsification control
This commit is contained in:
+4
-2
@@ -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);
|
||||
}
|
||||
|
||||
+74
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user