fix(historian): retention applock release hardening, shutdown-quiet cancellation, release tests, docs (#667 review)

This commit is contained in:
Joseph Doherty
2026-10-11 08:27:31 -04:00
parent 924eff8d3c
commit e4123ada91
4 changed files with 69 additions and 15 deletions
@@ -119,15 +119,56 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(6);
}
/// <summary>True when a fresh, non-pooled session can take the retention applock (i.e. nobody holds it).</summary>
private async Task<bool> LockIsFreeAsync(string s)
{
var cs = new SqlConnectionStringBuilder(fx.ConnectionString) { Pooling = false }.ConnectionString;
await using var c = new SqlConnection(cs);
await c.OpenAsync();
await using var cmd = c.CreateCommand();
cmd.CommandText = $"DECLARE @r int; EXEC @r = sp_getapplock @Resource = N'OtOpcUa.Historian.Retention.{s}', @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = 0; SELECT @r;";
return Convert.ToInt32(await cmd.ExecuteScalarAsync()) >= 0;
}
[Fact]
public async Task Cancellation_stops_the_purge()
public async Task The_applock_is_released_after_a_purge()
{
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));
await purger.PurgeAsync(CancellationToken.None);
// A pooled-session re-entry would hide a missing release; a separate non-pooled session cannot.
(await LockIsFreeAsync(s)).ShouldBeTrue();
}
[Fact]
public async Task Cancelling_between_batches_stops_the_purge_and_still_releases_the_applock()
{
fx.SkipIfUnavailable();
var (c, b, _, s) = Make();
await SeedAsync(b, s);
using var cts = new CancellationTokenSource();
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 1, time: new FixedTime(Now));
var batches = 0;
purger.AfterBatch = () => { if (++batches == 2) cts.Cancel(); };
await Should.ThrowAsync<OperationCanceledException>(() => purger.PurgeAsync(cts.Token));
batches.ShouldBe(2);
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(19); // 21 - two batches of 1
(await LockIsFreeAsync(s)).ShouldBeTrue();
}
[Fact]
public async Task A_throwing_purge_releases_the_applock()
{
fx.SkipIfUnavailable();
var (c, b, _, s) = Make();
await SeedAsync(b, s);
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 1, time: new FixedTime(Now));
using var cts = new CancellationTokenSource();
cts.Cancel();
await Should.ThrowAsync<OperationCanceledException>(() => purger.PurgeAsync(cts.Token));
purger.AfterBatch = () => throw new InvalidOperationException("boom");
await Should.ThrowAsync<InvalidOperationException>(() => purger.PurgeAsync(CancellationToken.None));
(await LockIsFreeAsync(s)).ShouldBeTrue();
}
}