279 lines
11 KiB
C#
279 lines
11 KiB
C#
using System.Text.Json;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for upstream-redundancy failover (PR-14, issue #286). The driver
|
|
/// exposes two test seams — <see cref="OpcUaClientDriver.InjectServiceLevelDropForTest"/>
|
|
/// and <see cref="OpcUaClientDriver.RedundancyFailoverHookForTest"/> — that bypass
|
|
/// the SDK's session-create + TransferSubscriptions machinery so we can assert the
|
|
/// decision logic without standing up two real OPC UA sessions.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientRedundancyTests
|
|
{
|
|
[Fact]
|
|
public void Redundancy_options_default_to_disabled()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions();
|
|
opts.Redundancy.ShouldNotBeNull();
|
|
opts.Redundancy.Enabled.ShouldBeFalse(
|
|
"default deployments do client-side failover via EndpointUrls; upstream redundancy is opt-in");
|
|
opts.Redundancy.ServiceLevelThreshold.ShouldBe((ushort)200,
|
|
"OPC UA spec convention: 200+ = healthy, lower = degraded");
|
|
opts.Redundancy.ResolvedRecheckInterval.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void DTO_json_round_trip_preserves_redundancy_settings()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(
|
|
Enabled: true,
|
|
ServiceLevelThreshold: 150,
|
|
RecheckInterval: TimeSpan.FromSeconds(10)),
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(opts);
|
|
var roundTripped = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json);
|
|
|
|
roundTripped.ShouldNotBeNull();
|
|
roundTripped!.Redundancy.Enabled.ShouldBeTrue();
|
|
roundTripped.Redundancy.ServiceLevelThreshold.ShouldBe((ushort)150);
|
|
roundTripped.Redundancy.ResolvedRecheckInterval.ShouldBe(TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
[Fact]
|
|
public void Disabled_redundancy_does_not_failover_on_low_servicelevel()
|
|
{
|
|
// Even a value of 0 (unrecoverable per the spec) should be a no-op when the
|
|
// feature is disabled — the driver shouldn't be reading ServerArray or watching
|
|
// ServiceLevel at all in that mode.
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(Enabled: false),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-disabled");
|
|
var hookFired = false;
|
|
drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); };
|
|
|
|
drv.InjectServiceLevelDropForTest(0);
|
|
|
|
hookFired.ShouldBeFalse(
|
|
"Redundancy.Enabled=false means ServiceLevel drops must not trigger failover");
|
|
drv.RedundancyFailoverInvocationsForTest.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void ServiceLevel_above_threshold_does_not_trigger_failover()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(Enabled: true, ServiceLevelThreshold: 200),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-healthy");
|
|
SeedPeers(drv, "opc.tcp://secondary:4840");
|
|
var hookFired = false;
|
|
drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); };
|
|
|
|
// Equal to threshold = healthy boundary; spec semantics treat 200 as healthy.
|
|
drv.InjectServiceLevelDropForTest(200);
|
|
// Just above threshold = healthy.
|
|
drv.InjectServiceLevelDropForTest(220);
|
|
|
|
hookFired.ShouldBeFalse(
|
|
"ServiceLevel >= threshold must not trigger failover — healthy primary stays put");
|
|
}
|
|
|
|
[Fact]
|
|
public void ServiceLevel_below_threshold_triggers_failover_with_secondary_uri()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(Enabled: true, ServiceLevelThreshold: 200),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-failover");
|
|
SeedPeers(drv, "opc.tcp://primary:4840", "opc.tcp://secondary:4840");
|
|
SeedActive(drv, "opc.tcp://primary:4840");
|
|
|
|
string? failoverTarget = null;
|
|
drv.RedundancyFailoverHookForTest = (uri, _) =>
|
|
{
|
|
failoverTarget = uri;
|
|
return Task.FromResult(true);
|
|
};
|
|
|
|
drv.InjectServiceLevelDropForTest(50);
|
|
|
|
// Wait for the fire-and-forget Task to complete. The driver dispatches FailoverAsync
|
|
// via discard — give it a beat to land.
|
|
Wait(() => failoverTarget is not null);
|
|
|
|
failoverTarget.ShouldBe("opc.tcp://secondary:4840",
|
|
"the failover path picks the next URI in ServerArray that isn't the active one");
|
|
var diags1 = drv.GetHealth().Diagnostics;
|
|
diags1.ShouldNotBeNull();
|
|
diags1!.ShouldContainKey("RedundancyFailoverCount");
|
|
diags1["RedundancyFailoverCount"].ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void Empty_peer_list_does_not_trigger_failover()
|
|
{
|
|
// Upstream with RedundancySupport=None (or one that simply doesn't expose the
|
|
// ServerUriArray node) leaves _redundancyPeers empty. ServiceLevel drops in that
|
|
// mode are diagnostic-only — the driver has no peer to swap to.
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(Enabled: true),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-no-peers");
|
|
var hookFired = false;
|
|
drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); };
|
|
|
|
drv.InjectServiceLevelDropForTest(50);
|
|
|
|
hookFired.ShouldBeFalse(
|
|
"ServerArray empty means there's nowhere to fail over to — drop is informational only");
|
|
}
|
|
|
|
[Fact]
|
|
public void Failover_with_only_active_uri_in_peer_list_does_not_swap_to_self()
|
|
{
|
|
// Edge case: the upstream advertises itself in ServerUriArray but no actual peers.
|
|
// The driver must not try to fail over to the URI it's already on.
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(Enabled: true),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-self-only");
|
|
SeedPeers(drv, "opc.tcp://primary:4840");
|
|
SeedActive(drv, "opc.tcp://primary:4840");
|
|
var hookFired = false;
|
|
drv.RedundancyFailoverHookForTest = (_, _) => { hookFired = true; return Task.FromResult(true); };
|
|
|
|
drv.InjectServiceLevelDropForTest(50);
|
|
|
|
hookFired.ShouldBeFalse(
|
|
"the only peer in the list is the active URI itself — there's nothing to swap to");
|
|
}
|
|
|
|
[Fact]
|
|
public void Failover_failure_increments_failures_counter_and_keeps_session()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(Enabled: true),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-failure");
|
|
SeedPeers(drv, "opc.tcp://primary:4840", "opc.tcp://secondary:4840");
|
|
SeedActive(drv, "opc.tcp://primary:4840");
|
|
|
|
drv.RedundancyFailoverHookForTest = (_, _) => Task.FromResult(false);
|
|
|
|
drv.InjectServiceLevelDropForTest(50);
|
|
|
|
Wait(() => drv.GetHealth().Diagnostics is { } d
|
|
&& d.TryGetValue("RedundancyFailoverFailures", out var f) && f >= 1);
|
|
|
|
var diags = drv.GetHealth().Diagnostics;
|
|
diags.ShouldNotBeNull();
|
|
diags!.ShouldContainKey("RedundancyFailoverFailures");
|
|
diags["RedundancyFailoverFailures"].ShouldBe(1);
|
|
diags["RedundancyFailoverCount"].ShouldBe(0,
|
|
"a failed swap must not bump the success counter");
|
|
}
|
|
|
|
[Fact]
|
|
public void Repeated_drops_within_recheck_interval_only_failover_once()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(
|
|
Enabled: true,
|
|
ServiceLevelThreshold: 200,
|
|
RecheckInterval: TimeSpan.FromMinutes(5)),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-debounce");
|
|
SeedPeers(drv, "opc.tcp://primary:4840", "opc.tcp://secondary:4840");
|
|
SeedActive(drv, "opc.tcp://primary:4840");
|
|
|
|
var calls = 0;
|
|
drv.RedundancyFailoverHookForTest = (_, _) =>
|
|
{
|
|
Interlocked.Increment(ref calls);
|
|
return Task.FromResult(true);
|
|
};
|
|
|
|
drv.InjectServiceLevelDropForTest(50);
|
|
Wait(() => calls >= 1);
|
|
drv.InjectServiceLevelDropForTest(50);
|
|
drv.InjectServiceLevelDropForTest(40);
|
|
|
|
// RecheckInterval = 5 minutes — the second + third drops should be suppressed
|
|
// because the first failover landed inside the window.
|
|
calls.ShouldBe(1,
|
|
"RecheckInterval suppresses oscillation around the threshold so a flapping primary doesn't ping-pong");
|
|
}
|
|
|
|
[Fact]
|
|
public void Diagnostics_exposes_redundancy_counters_in_snapshot()
|
|
{
|
|
// The `driver-diagnostics` RPC reads through GetHealth(); operators expect the
|
|
// redundancy counters in the snapshot regardless of whether failover ever fired.
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
EndpointUrl = "opc.tcp://primary:4840",
|
|
Redundancy = new RedundancyOptions(Enabled: true),
|
|
};
|
|
using var drv = new OpcUaClientDriver(opts, "opcua-redundancy-diag");
|
|
|
|
var d = drv.GetHealth().Diagnostics;
|
|
d.ShouldNotBeNull();
|
|
d!.ShouldContainKey("RedundancyFailoverCount");
|
|
d.ShouldContainKey("RedundancyFailoverFailures");
|
|
d["RedundancyFailoverCount"].ShouldBe(0);
|
|
d["RedundancyFailoverFailures"].ShouldBe(0);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
private static void SeedPeers(OpcUaClientDriver drv, params string[] peers)
|
|
{
|
|
// The driver normally populates _redundancyPeers from a session ReadValue call.
|
|
// For unit testing we use reflection to seed the field directly — the alternative
|
|
// (mocking ISession) brings most of the OPC UA SDK into the test surface.
|
|
var field = typeof(OpcUaClientDriver).GetField(
|
|
"_redundancyPeers",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
|
|
field.SetValue(drv, (IReadOnlyList<string>)peers);
|
|
}
|
|
|
|
private static void SeedActive(OpcUaClientDriver drv, string uri)
|
|
{
|
|
var diag = drv.DiagnosticsForTest;
|
|
diag.SetActiveServerUri(uri);
|
|
}
|
|
|
|
private static void Wait(Func<bool> predicate, int timeoutMs = 2000)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (predicate()) return;
|
|
Thread.Sleep(10);
|
|
}
|
|
}
|
|
}
|