Resolve blocking I/O finding and complete Historian lifecycle test coverage
Move subscribe/unsubscribe I/O outside lock(Lock) in SyncAddressSpace to avoid blocking all OPC UA operations during rebuilds. Replace blocking ReadAsync calls for alarm priority/description in dispatch loop with cached subscription values. Extract IHistorianConnectionFactory so EnsureConnected can be tested without the SDK runtime — adds 5 connection lifecycle tests (failure, timeout, reconnect, state resilience, dispose-after-failure). All stability review findings and test coverage gaps are now fully resolved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using ArchestrA;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake Historian connection factory for tests. Controls whether connections
|
||||
/// succeed, fail, or timeout without requiring the real Historian SDK runtime.
|
||||
/// </summary>
|
||||
internal sealed class FakeHistorianConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// When set, <see cref="CreateAndConnect"/> throws this exception.
|
||||
/// </summary>
|
||||
public Exception? ConnectException { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of times <see cref="CreateAndConnect"/> has been called.
|
||||
/// </summary>
|
||||
public int ConnectCallCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, called on each <see cref="CreateAndConnect"/> to determine behavior.
|
||||
/// Receives the call count (1-based). Return null to succeed, or throw to fail.
|
||||
/// </summary>
|
||||
public Action<int>? OnConnect { get; set; }
|
||||
|
||||
public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
|
||||
{
|
||||
ConnectCallCount++;
|
||||
|
||||
if (OnConnect != null)
|
||||
{
|
||||
OnConnect(ConnectCallCount);
|
||||
}
|
||||
else if (ConnectException != null)
|
||||
{
|
||||
throw ConnectException;
|
||||
}
|
||||
|
||||
// Return a HistorianAccess that is not actually connected.
|
||||
// ReadRawAsync etc. will fail when they try to use it, which exercises
|
||||
// the HandleConnectionError → reconnect path.
|
||||
return new HistorianAccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
|
||||
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies Historian data source lifecycle behavior: dispose safety,
|
||||
/// post-dispose rejection, and double-dispose idempotency.
|
||||
/// post-dispose rejection, connection failure handling, and reconnect-after-error.
|
||||
/// </summary>
|
||||
public class HistorianDataSourceLifecycleTests
|
||||
{
|
||||
@@ -79,5 +80,100 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
|
||||
{
|
||||
HistorianDataSource.MapAggregateToColumn(new Opc.Ua.NodeId(99999)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_WhenConnectionFails_ReturnsEmptyResults()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("Connection refused")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
results.Count.ShouldBe(0);
|
||||
factory.ConnectCallCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_WhenConnectionTimesOut_ReturnsEmptyResults()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new TimeoutException("Connection timed out")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
var results = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
results.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_AfterConnectionError_AttemptsReconnect()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory();
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
// First call: factory returns a HistorianAccess that isn't actually connected,
|
||||
// so the query will fail and HandleConnectionError will reset the connection.
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Second call: should attempt reconnection via the factory
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Factory should have been called twice — once for initial connect, once for reconnect
|
||||
factory.ConnectCallCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRawAsync_ConnectionFailure_DoesNotCorruptState()
|
||||
{
|
||||
var callCount = 0;
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
OnConnect = count =>
|
||||
{
|
||||
callCount = count;
|
||||
if (count == 1)
|
||||
throw new InvalidOperationException("First connection fails");
|
||||
// Second call succeeds (returns unconnected HistorianAccess, but that's OK for lifecycle testing)
|
||||
}
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
// First read: connection fails
|
||||
var r1 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
r1.Count.ShouldBe(0);
|
||||
|
||||
// Second read: should attempt new connection without throwing from internal state corruption
|
||||
var r2 = ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
callCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DuringConnectionFailure_DoesNotThrow()
|
||||
{
|
||||
var factory = new FakeHistorianConnectionFactory
|
||||
{
|
||||
ConnectException = new InvalidOperationException("Connection refused")
|
||||
};
|
||||
var ds = new HistorianDataSource(DefaultConfig, factory);
|
||||
|
||||
// Trigger a failed connection attempt
|
||||
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
// Dispose should handle the null connection gracefully
|
||||
Should.NotThrow(() => ds.Dispose());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="aahClientCommon">
|
||||
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user