fix(historian-gateway): dispose alarm-write channel at shutdown + ServerHistorian startup diagnostic

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 17:55:44 -04:00
parent 22711444cc
commit 035bde0562
4 changed files with 28 additions and 2 deletions
@@ -732,12 +732,13 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
/// <summary>Gets the current exponential backoff delay for retry operations.</summary>
public TimeSpan CurrentBackoff => BackoffLadder[_backoffIndex];
/// <summary>Disposes the sink and releases all held resources including the drain timer.</summary>
/// <summary>Disposes the sink and releases all held resources including the drain timer and the writer.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_drainTimer?.Dispose();
_drainGate.Dispose();
if (_writer is IDisposable writerDisposable) writerDisposable.Dispose();
}
}
@@ -36,7 +36,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// event forever.
/// </para>
/// </remarks>
public sealed class GatewayAlarmHistorianWriter : IAlarmHistorianWriter
public sealed class GatewayAlarmHistorianWriter : IAlarmHistorianWriter, IDisposable
{
private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayAlarmHistorianWriter> _logger;
@@ -154,4 +154,11 @@ public sealed class GatewayAlarmHistorianWriter : IAlarmHistorianWriter
// Unknown/unclassified gRPC code → dead-letter to avoid an infinite drain loop.
_ => HistorianWriteOutcome.PermanentFail,
};
/// <summary>
/// Disposes the underlying gateway client and its gRPC channel. The concrete
/// <see cref="HistorianGatewayClientAdapter"/> implements <see cref="IDisposable"/>; test doubles
/// that only implement <see cref="IAsyncDisposable"/> are safely no-opped by the cast guard.
/// </summary>
public void Dispose() => (_client as IDisposable)?.Dispose();
}
@@ -109,6 +109,8 @@ if (hasDriver)
var serverHistorianOptions = builder.Configuration
.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>()
?? new ServerHistorianOptions();
foreach (var warning in serverHistorianOptions.Validate())
Log.Warning("ServerHistorian misconfiguration detected at startup: {Warning}", warning);
builder.Services.AddAlarmHistorian(
builder.Configuration,
(_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp));
@@ -199,4 +199,20 @@ public sealed class GatewayAlarmHistorianWriterTests
Assert.Empty(outcomes);
}
[Fact]
public void Dispose_with_async_only_client_does_not_throw()
{
// FakeHistorianGatewayClient implements IAsyncDisposable only — not IDisposable.
// The `as IDisposable` guard in GatewayAlarmHistorianWriter.Dispose() must safely
// no-op rather than throw when the client cannot be cast to IDisposable.
var fake = new FakeHistorianGatewayClient();
var writer = Writer(fake);
var ex = Record.Exception(() => ((IDisposable)writer).Dispose());
Assert.Null(ex);
// The Fake is IAsyncDisposable only; the sync Dispose must not call DisposeAsync.
Assert.Equal(0, fake.DisposeCallCount);
}
}