Add configurable non-transparent OPC UA server redundancy

Separates ApplicationUri from namespace identity so each instance in a
redundant pair has a unique server URI while sharing the same Galaxy
namespace. Exposes RedundancySupport, ServerUriArray, and dynamic
ServiceLevel through the standard OPC UA server object. ServiceLevel
is computed from role (Primary/Secondary) and runtime health (MXAccess
and DB connectivity). Adds CLI redundancy command, second deployed
service instance, and 31 new tests including paired-server integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-28 13:32:17 -04:00
parent a3c2d9b243
commit a55153d7d5
27 changed files with 1475 additions and 248 deletions

View File

@@ -0,0 +1,44 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
{
public class RedundancyConfigurationTests
{
[Fact]
public void DefaultConfig_Disabled()
{
var config = new RedundancyConfiguration();
config.Enabled.ShouldBe(false);
}
[Fact]
public void DefaultConfig_ModeWarm()
{
var config = new RedundancyConfiguration();
config.Mode.ShouldBe("Warm");
}
[Fact]
public void DefaultConfig_RolePrimary()
{
var config = new RedundancyConfiguration();
config.Role.ShouldBe("Primary");
}
[Fact]
public void DefaultConfig_EmptyServerUris()
{
var config = new RedundancyConfiguration();
config.ServerUris.ShouldBeEmpty();
}
[Fact]
public void DefaultConfig_ServiceLevelBase200()
{
var config = new RedundancyConfiguration();
config.ServiceLevelBase.ShouldBe(200);
}
}
}

View File

@@ -0,0 +1,54 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
{
public class RedundancyModeResolverTests
{
[Fact]
public void Resolve_Disabled_ReturnsNone()
{
RedundancyModeResolver.Resolve("Warm", enabled: false).ShouldBe(RedundancySupport.None);
}
[Fact]
public void Resolve_Warm_ReturnsWarm()
{
RedundancyModeResolver.Resolve("Warm", enabled: true).ShouldBe(RedundancySupport.Warm);
}
[Fact]
public void Resolve_Hot_ReturnsHot()
{
RedundancyModeResolver.Resolve("Hot", enabled: true).ShouldBe(RedundancySupport.Hot);
}
[Fact]
public void Resolve_Unknown_FallsBackToNone()
{
RedundancyModeResolver.Resolve("Transparent", enabled: true).ShouldBe(RedundancySupport.None);
}
[Fact]
public void Resolve_CaseInsensitive()
{
RedundancyModeResolver.Resolve("warm", enabled: true).ShouldBe(RedundancySupport.Warm);
RedundancyModeResolver.Resolve("WARM", enabled: true).ShouldBe(RedundancySupport.Warm);
RedundancyModeResolver.Resolve("hot", enabled: true).ShouldBe(RedundancySupport.Hot);
}
[Fact]
public void Resolve_Null_FallsBackToNone()
{
RedundancyModeResolver.Resolve(null!, enabled: true).ShouldBe(RedundancySupport.None);
}
[Fact]
public void Resolve_Empty_FallsBackToNone()
{
RedundancyModeResolver.Resolve("", enabled: true).ShouldBe(RedundancySupport.None);
}
}
}

View File

@@ -0,0 +1,59 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
{
public class ServiceLevelCalculatorTests
{
private readonly ServiceLevelCalculator _calculator = new ServiceLevelCalculator();
[Fact]
public void FullyHealthy_Primary_ReturnsBase()
{
_calculator.Calculate(200, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)200);
}
[Fact]
public void FullyHealthy_Secondary_ReturnsBaseMinusFifty()
{
_calculator.Calculate(150, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)150);
}
[Fact]
public void MxAccessDown_ReducesServiceLevel()
{
_calculator.Calculate(200, mxAccessConnected: false, dbConnected: true).ShouldBe((byte)100);
}
[Fact]
public void DbDown_ReducesServiceLevel()
{
_calculator.Calculate(200, mxAccessConnected: true, dbConnected: false).ShouldBe((byte)150);
}
[Fact]
public void BothDown_ReturnsZero()
{
_calculator.Calculate(200, mxAccessConnected: false, dbConnected: false).ShouldBe((byte)0);
}
[Fact]
public void ClampedTo255()
{
_calculator.Calculate(255, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)255);
}
[Fact]
public void ClampedToZero()
{
_calculator.Calculate(50, mxAccessConnected: false, dbConnected: true).ShouldBe((byte)0);
}
[Fact]
public void ZeroBase_BothHealthy_ReturnsZero()
{
_calculator.Calculate(0, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)0);
}
}
}