One invoker per (DriverInstance, IDriver) pair; calls ExecuteAsync(capability, host, callSite) and the invoker resolves the correct pipeline from the shared DriverResiliencePipelineBuilder. The options accessor is a Func so Admin-edit + pipeline-invalidate takes effect without restarting the invoker or the driver host. ExecuteWriteAsync(isIdempotent) is the explicit write-safety surface: - isIdempotent=false routes through a side pipeline with RetryCount=0 regardless of what the caller configured. The cache key carries a "::non-idempotent" suffix so it never collides with the retry-enabled write pipeline. - isIdempotent=true routes through the normal Write pipeline. If the user has configured Write retries (opt-in), the idempotent tag gets them; otherwise default-0 still wins. The server dispatch layer (next PR) reads WriteIdempotentAttribute on each tag definition once at driver-init time and feeds the boolean into ExecuteWriteAsync. Tests (6 new): - Read retries on transient failure; returns value from call site. - Write non-idempotent does NOT retry even when policy has 3 retries configured (the explicit decision-#44 guard at the dispatch surface). - Write idempotent retries when policy allows. - Write with default tier-A policy (RetryCount=0) never retries regardless of idempotency flag. - Different hosts get independent pipelines. Core.Tests now 44 passing (was 38). Invoker doc-refs completed (the XML comment on WriteIdempotentAttribute no longer references a non-existent type). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
5.0 KiB
C#
152 lines
5.0 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;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class CapabilityInvokerTests
|
|
{
|
|
private static CapabilityInvoker MakeInvoker(
|
|
DriverResiliencePipelineBuilder builder,
|
|
DriverResilienceOptions options) =>
|
|
new(builder, Guid.NewGuid(), () => options);
|
|
|
|
[Fact]
|
|
public async Task Read_ReturnsValue_FromCallSite()
|
|
{
|
|
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A });
|
|
|
|
var result = await invoker.ExecuteAsync(
|
|
DriverCapability.Read,
|
|
"host-1",
|
|
_ => ValueTask.FromResult(42),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe(42);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Read_Retries_OnTransientFailure()
|
|
{
|
|
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A });
|
|
var attempts = 0;
|
|
|
|
var result = await invoker.ExecuteAsync(
|
|
DriverCapability.Read,
|
|
"host-1",
|
|
async _ =>
|
|
{
|
|
attempts++;
|
|
if (attempts < 2) throw new InvalidOperationException("transient");
|
|
await Task.Yield();
|
|
return "ok";
|
|
},
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe("ok");
|
|
attempts.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Write_NonIdempotent_DoesNotRetry_EvenWhenPolicyHasRetries()
|
|
{
|
|
var options = new DriverResilienceOptions
|
|
{
|
|
Tier = DriverTier.A,
|
|
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
|
{
|
|
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
|
|
},
|
|
};
|
|
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), options);
|
|
var attempts = 0;
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await invoker.ExecuteWriteAsync(
|
|
"host-1",
|
|
isIdempotent: false,
|
|
async _ =>
|
|
{
|
|
attempts++;
|
|
await Task.Yield();
|
|
throw new InvalidOperationException("boom");
|
|
#pragma warning disable CS0162
|
|
return 0;
|
|
#pragma warning restore CS0162
|
|
},
|
|
CancellationToken.None));
|
|
|
|
attempts.ShouldBe(1, "non-idempotent write must never replay");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Write_Idempotent_Retries_WhenPolicyHasRetries()
|
|
{
|
|
var options = new DriverResilienceOptions
|
|
{
|
|
Tier = DriverTier.A,
|
|
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
|
{
|
|
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
|
|
},
|
|
};
|
|
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), options);
|
|
var attempts = 0;
|
|
|
|
var result = await invoker.ExecuteWriteAsync(
|
|
"host-1",
|
|
isIdempotent: true,
|
|
async _ =>
|
|
{
|
|
attempts++;
|
|
if (attempts < 2) throw new InvalidOperationException("transient");
|
|
await Task.Yield();
|
|
return "ok";
|
|
},
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe("ok");
|
|
attempts.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Write_Default_DoesNotRetry_WhenPolicyHasZeroRetries()
|
|
{
|
|
// Tier A Write default is RetryCount=0. Even isIdempotent=true shouldn't retry
|
|
// because the policy says not to.
|
|
var invoker = MakeInvoker(new DriverResiliencePipelineBuilder(), new DriverResilienceOptions { Tier = DriverTier.A });
|
|
var attempts = 0;
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
|
await invoker.ExecuteWriteAsync(
|
|
"host-1",
|
|
isIdempotent: true,
|
|
async _ =>
|
|
{
|
|
attempts++;
|
|
await Task.Yield();
|
|
throw new InvalidOperationException("boom");
|
|
#pragma warning disable CS0162
|
|
return 0;
|
|
#pragma warning restore CS0162
|
|
},
|
|
CancellationToken.None));
|
|
|
|
attempts.ShouldBe(1, "tier-A default for Write is RetryCount=0");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_HonorsDifferentHosts_Independently()
|
|
{
|
|
var builder = new DriverResiliencePipelineBuilder();
|
|
var invoker = MakeInvoker(builder, new DriverResilienceOptions { Tier = DriverTier.A });
|
|
|
|
await invoker.ExecuteAsync(DriverCapability.Read, "host-a", _ => ValueTask.FromResult(1), CancellationToken.None);
|
|
await invoker.ExecuteAsync(DriverCapability.Read, "host-b", _ => ValueTask.FromResult(2), CancellationToken.None);
|
|
|
|
builder.CachedPipelineCount.ShouldBe(2);
|
|
}
|
|
}
|