Merge fix/667-followups: release the retention applock only when acquired (#667 live-gate F3)
This commit is contained in:
+21
-4
@@ -39,15 +39,31 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
|||||||
/// <summary>Test hook invoked after every delete batch.</summary>
|
/// <summary>Test hook invoked after every delete batch.</summary>
|
||||||
internal Action? AfterBatch { get; set; }
|
internal Action? AfterBatch { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Test seam for <see cref="SqlConnection.ClearPool"/>.</summary>
|
||||||
|
internal Action<SqlConnection> ClearPool { get; set; } = SqlConnection.ClearPool;
|
||||||
|
|
||||||
public async Task<IReadOnlyList<RetentionPurgeResult>> PurgeAsync(CancellationToken ct)
|
public async Task<IReadOnlyList<RetentionPurgeResult>> PurgeAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||||
var cutoff = _time.GetUtcNow().UtcDateTime.AddDays(-_days);
|
var cutoff = _time.GetUtcNow().UtcDateTime.AddDays(-_days);
|
||||||
|
|
||||||
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
var acquired = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!await TryLockAsync(conn, ct).ConfigureAwait(false))
|
try
|
||||||
|
{
|
||||||
|
acquired = await TryLockAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ambiguous: the server may have granted the lock before the client saw the reply. We cannot
|
||||||
|
// release what we are not sure we hold, so close the session physically instead.
|
||||||
|
ClearPool(conn);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquired)
|
||||||
{
|
{
|
||||||
_log.LogInformation("Historian retention purge skipped: another purger holds the applock.");
|
_log.LogInformation("Historian retention purge skipped: another purger holds the applock.");
|
||||||
return [];
|
return [];
|
||||||
@@ -74,8 +90,9 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// Also runs when acquire threw: the server may have granted the lock before the client saw the reply.
|
// Only when WE hold it: releasing an applock we were refused fails, which would log a false Warning
|
||||||
await ReleaseAsync(conn).ConfigureAwait(false);
|
// and clear the shared pool on every skipped interval.
|
||||||
|
if (acquired) await ReleaseAsync(conn).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +138,7 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
|
|||||||
// applock); clearing the pool makes the dispose physically close it, which releases the lock.
|
// applock); clearing the pool makes the dispose physically close it, which releases the lock.
|
||||||
_log.LogWarning("sp_releaseapplock failed ({Exception}); discarding the connection pool so the session " +
|
_log.LogWarning("sp_releaseapplock failed ({Exception}); discarding the connection pool so the session " +
|
||||||
"holding the retention applock is closed rather than pooled.", ex.GetType().Name);
|
"holding the retention applock is closed rather than pooled.", ex.GetType().Name);
|
||||||
SqlConnection.ClearPool(conn);
|
ClearPool(conn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -171,4 +171,46 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
|
|||||||
await Should.ThrowAsync<InvalidOperationException>(() => purger.PurgeAsync(CancellationToken.None));
|
await Should.ThrowAsync<InvalidOperationException>(() => purger.PurgeAsync(CancellationToken.None));
|
||||||
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
(await LockIsFreeAsync(s)).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger
|
||||||
|
{
|
||||||
|
public readonly List<Microsoft.Extensions.Logging.LogLevel> Levels = [];
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||||
|
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||||
|
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
lock (Levels) Levels.Add(logLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_skipped_pass_logs_no_warning_and_does_not_clear_the_pool()
|
||||||
|
{
|
||||||
|
fx.SkipIfUnavailable();
|
||||||
|
var (c, b, _, s) = Make();
|
||||||
|
await SeedAsync(b, s);
|
||||||
|
|
||||||
|
await using var holder = new SqlConnection(fx.ConnectionString);
|
||||||
|
await holder.OpenAsync();
|
||||||
|
await using (var cmd = holder.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = $"EXEC sp_getapplock @Resource = N'OtOpcUa.Historian.Retention.{s}', @LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = 0;";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var log = new CapturingLogger();
|
||||||
|
var clears = 0;
|
||||||
|
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, log, new FixedTime(Now))
|
||||||
|
{
|
||||||
|
ClearPool = _ => Interlocked.Increment(ref clears),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++) (await purger.PurgeAsync(CancellationToken.None)).ShouldBeEmpty();
|
||||||
|
|
||||||
|
lock (log.Levels) log.Levels.ShouldNotContain(l => l >= Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||||
|
clears.ShouldBe(0);
|
||||||
|
// The shared pool is still healthy.
|
||||||
|
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(21);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user