64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
121 lines
5.3 KiB
C#
121 lines
5.3 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
|
|
|
/// <summary>
|
|
/// Exercises the per-call host resolver contract against the shared
|
|
/// <see cref="DriverResiliencePipelineBuilder"/> + <see cref="CapabilityInvoker"/> — one
|
|
/// dead PLC behind a multi-device driver must NOT open the breaker for healthy sibling
|
|
/// PLCs (decision #144).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class PerCallHostResolverDispatchTests
|
|
{
|
|
private sealed class StaticResolver : IPerCallHostResolver
|
|
{
|
|
private readonly Dictionary<string, string> _map;
|
|
|
|
/// <summary>Initializes a new instance of StaticResolver with a predefined mapping.</summary>
|
|
/// <param name="map">The mapping of full references to host names.</param>
|
|
public StaticResolver(Dictionary<string, string> map) => _map = map;
|
|
|
|
/// <summary>Resolves a host name from the static mapping.</summary>
|
|
/// <param name="fullReference">The full reference to resolve.</param>
|
|
public string ResolveHost(string fullReference) =>
|
|
_map.TryGetValue(fullReference, out var host) ? host : string.Empty;
|
|
}
|
|
|
|
/// <summary>Verifies that a dead PLC does not open the breaker for healthy PLCs when using a per-call resolver.</summary>
|
|
[Fact]
|
|
public async Task DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver()
|
|
{
|
|
// Two PLCs behind one driver. Dead PLC keeps failing; healthy PLC must keep serving.
|
|
var builder = new DriverResiliencePipelineBuilder();
|
|
var options = new DriverResilienceOptions { Tier = DriverTier.B };
|
|
var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options);
|
|
|
|
var resolver = new StaticResolver(new Dictionary<string, string>
|
|
{
|
|
["tag-on-dead"] = "plc-dead",
|
|
["tag-on-alive"] = "plc-alive",
|
|
});
|
|
|
|
var threshold = options.Resolve(DriverCapability.Read).BreakerFailureThreshold;
|
|
for (var i = 0; i < threshold + 3; i++)
|
|
{
|
|
await Should.ThrowAsync<Exception>(async () =>
|
|
await invoker.ExecuteAsync(
|
|
DriverCapability.Read,
|
|
hostName: resolver.ResolveHost("tag-on-dead"),
|
|
_ => throw new InvalidOperationException("plc-dead unreachable"),
|
|
CancellationToken.None));
|
|
}
|
|
|
|
// Healthy PLC's pipeline is in a different bucket; the first call should succeed
|
|
// without hitting the dead-PLC breaker.
|
|
var aliveAttempts = 0;
|
|
await invoker.ExecuteAsync(
|
|
DriverCapability.Read,
|
|
hostName: resolver.ResolveHost("tag-on-alive"),
|
|
_ => { aliveAttempts++; return ValueTask.FromResult("ok"); },
|
|
CancellationToken.None);
|
|
|
|
aliveAttempts.ShouldBe(1, "decision #144 — per-PLC isolation keeps healthy PLCs serving");
|
|
}
|
|
|
|
/// <summary>Verifies that empty string from resolver is treated as single-host fallback.</summary>
|
|
[Fact]
|
|
public void Resolver_EmptyString_Treated_As_Single_Host_Fallback()
|
|
{
|
|
var resolver = new StaticResolver(new Dictionary<string, string>
|
|
{
|
|
["tag-unknown"] = "",
|
|
});
|
|
|
|
resolver.ResolveHost("tag-unknown").ShouldBe("");
|
|
resolver.ResolveHost("not-in-map").ShouldBe("", "unknown refs return empty so dispatch falls back to single-host");
|
|
}
|
|
|
|
/// <summary>Verifies that without a resolver, the same host shares one resilience pipeline.</summary>
|
|
[Fact]
|
|
public async Task WithoutResolver_SameHost_Shares_One_Pipeline()
|
|
{
|
|
// Without a resolver all calls share the DriverInstanceId pipeline — that's the
|
|
// pre-decision-#144 behavior single-host drivers should keep.
|
|
var builder = new DriverResiliencePipelineBuilder();
|
|
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
|
var invoker = new CapabilityInvoker(builder, "drv-single", () => options);
|
|
|
|
await invoker.ExecuteAsync(DriverCapability.Read, "drv-single",
|
|
_ => ValueTask.FromResult("a"), CancellationToken.None);
|
|
await invoker.ExecuteAsync(DriverCapability.Read, "drv-single",
|
|
_ => ValueTask.FromResult("b"), CancellationToken.None);
|
|
|
|
builder.CachedPipelineCount.ShouldBe(1, "single-host drivers share one pipeline");
|
|
}
|
|
|
|
/// <summary>Verifies that with a resolver, different hosts get separate resilience pipelines.</summary>
|
|
[Fact]
|
|
public async Task WithResolver_TwoHosts_Get_Two_Pipelines()
|
|
{
|
|
var builder = new DriverResiliencePipelineBuilder();
|
|
var options = new DriverResilienceOptions { Tier = DriverTier.B };
|
|
var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options);
|
|
var resolver = new StaticResolver(new Dictionary<string, string>
|
|
{
|
|
["tag-a"] = "plc-a",
|
|
["tag-b"] = "plc-b",
|
|
});
|
|
|
|
await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-a"),
|
|
_ => ValueTask.FromResult(1), CancellationToken.None);
|
|
await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-b"),
|
|
_ => ValueTask.FromResult(2), CancellationToken.None);
|
|
|
|
builder.CachedPipelineCount.ShouldBe(2, "each host keyed on its own pipeline");
|
|
}
|
|
}
|