Extract historian into a runtime-loaded plugin so hosts without the Wonderware SDK can run with Historian.Enabled=false

The aahClientManaged SDK is now isolated in ZB.MOM.WW.LmxOpcUa.Historian.Aveva and loaded via HistorianPluginLoader from a Historian/ subfolder only when enabled, removing the SDK from Host's compile-time and deploy-time surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-12 15:16:07 -04:00
parent 9e1a180ce3
commit 9b42b61eb6
40 changed files with 739 additions and 15855 deletions

View File

@@ -1,49 +0,0 @@
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();
}
}
}

View File

@@ -5,55 +5,55 @@ using ZB.MOM.WW.LmxOpcUa.Host.Historian;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
{
public class HistorianDataSourceTests
public class HistorianAggregateMapTests
{
[Fact]
public void MapAggregateToColumn_Average_ReturnsAverage()
{
HistorianDataSource.MapAggregateToColumn(ObjectIds.AggregateFunction_Average).ShouldBe("Average");
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Average).ShouldBe("Average");
}
[Fact]
public void MapAggregateToColumn_Minimum_ReturnsMinimum()
{
HistorianDataSource.MapAggregateToColumn(ObjectIds.AggregateFunction_Minimum).ShouldBe("Minimum");
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Minimum).ShouldBe("Minimum");
}
[Fact]
public void MapAggregateToColumn_Maximum_ReturnsMaximum()
{
HistorianDataSource.MapAggregateToColumn(ObjectIds.AggregateFunction_Maximum).ShouldBe("Maximum");
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Maximum).ShouldBe("Maximum");
}
[Fact]
public void MapAggregateToColumn_Count_ReturnsValueCount()
{
HistorianDataSource.MapAggregateToColumn(ObjectIds.AggregateFunction_Count).ShouldBe("ValueCount");
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Count).ShouldBe("ValueCount");
}
[Fact]
public void MapAggregateToColumn_Start_ReturnsFirst()
{
HistorianDataSource.MapAggregateToColumn(ObjectIds.AggregateFunction_Start).ShouldBe("First");
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_Start).ShouldBe("First");
}
[Fact]
public void MapAggregateToColumn_End_ReturnsLast()
{
HistorianDataSource.MapAggregateToColumn(ObjectIds.AggregateFunction_End).ShouldBe("Last");
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_End).ShouldBe("Last");
}
[Fact]
public void MapAggregateToColumn_StdDev_ReturnsStdDev()
{
HistorianDataSource.MapAggregateToColumn(ObjectIds.AggregateFunction_StandardDeviationPopulation)
HistorianAggregateMap.MapAggregateToColumn(ObjectIds.AggregateFunction_StandardDeviationPopulation)
.ShouldBe("StdDev");
}
[Fact]
public void MapAggregateToColumn_Unsupported_ReturnsNull()
{
HistorianDataSource.MapAggregateToColumn(new NodeId(99999)).ShouldBeNull();
HistorianAggregateMap.MapAggregateToColumn(new NodeId(99999)).ShouldBeNull();
}
}
}

View File

@@ -1,179 +0,0 @@
using System;
using System.Threading;
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, connection failure handling, and reconnect-after-error.
/// </summary>
public class HistorianDataSourceLifecycleTests
{
private static HistorianConfiguration DefaultConfig => new()
{
Enabled = true,
ServerName = "test-historian",
Port = 32568,
IntegratedSecurity = true,
CommandTimeoutSeconds = 5
};
[Fact]
public void ReadRawAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadRawAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAggregateAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadAggregateAsync("Tag1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 60000, "Average")
.GetAwaiter().GetResult());
}
[Fact]
public void ReadAtTimeAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadAtTimeAsync("Tag1", new[] { DateTime.UtcNow })
.GetAwaiter().GetResult());
}
[Fact]
public void ReadEventsAsync_AfterDispose_ThrowsObjectDisposedException()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.Throw<ObjectDisposedException>(() =>
ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 100)
.GetAwaiter().GetResult());
}
[Fact]
public void Dispose_CalledTwice_DoesNotThrow()
{
var ds = new HistorianDataSource(DefaultConfig);
ds.Dispose();
Should.NotThrow(() => ds.Dispose());
}
[Fact]
public void ExtractAggregateValue_UnknownColumn_ReturnsNull()
{
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());
}
}
}

View File

@@ -34,14 +34,6 @@
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientManaged">
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientCommon">
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>