- Core-004: add ConfigureAwait(false) to DriverHost.RegisterAsync / UnregisterAsync / DisposeAsync. - Core-008: rewrite the BuildAddressSpaceAsync XML doc to correctly name the caller (OpcUaApplicationHost.PopulateAddressSpaces) that owns the per-driver isolation. - Core-009: snapshot DriverResilienceOptions once per non-idempotent write in CapabilityInvoker.ExecuteWriteAsync. - Core-010: switch DriverResilienceOptions.Resolve to TryGetValue with a diagnostic error message when a tier table is missing a capability. - Core-011: add an optional diagnostic callback to PermissionTrieBuilder so production callers can surface scope-path mismatches. - Core-012: correct the stale WedgeDetector ctor summary and add the Reconnecting row to DriverHealthReport's state matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.9 KiB
C#
214 lines
7.9 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, "drv-test", () => 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core-009 regression: ExecuteWriteAsync's non-idempotent branch must snapshot
|
|
/// <c>_optionsAccessor</c> exactly once per call. Calling it multiple times allocates
|
|
/// redundant options objects on the per-write hot path and creates a consistency hazard
|
|
/// where an Admin edit mid-call could observe two different snapshots.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteWriteAsync_NonIdempotent_Snapshots_Options_Once_Per_Call()
|
|
{
|
|
var options = new DriverResilienceOptions
|
|
{
|
|
Tier = DriverTier.A,
|
|
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
|
{
|
|
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
|
|
},
|
|
};
|
|
var accessorCalls = 0;
|
|
var invoker = new CapabilityInvoker(
|
|
new DriverResiliencePipelineBuilder(),
|
|
"drv-test",
|
|
() => { Interlocked.Increment(ref accessorCalls); return options; });
|
|
|
|
await invoker.ExecuteWriteAsync(
|
|
"host-1",
|
|
isIdempotent: false,
|
|
_ => ValueTask.FromResult(0),
|
|
CancellationToken.None);
|
|
|
|
accessorCalls.ShouldBe(1,
|
|
"ExecuteWriteAsync's non-idempotent branch must capture the options snapshot exactly once per call");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core-009 regression — companion consistency assertion: the non-idempotent branch must
|
|
/// not observe two different option snapshots even if the accessor's returned value changes
|
|
/// between calls (simulating an Admin edit landing mid-flight). With a single snapshot the
|
|
/// two derived values (<c>with</c> base + <c>Resolve(Write)</c>) come from the same options
|
|
/// instance.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ExecuteWriteAsync_NonIdempotent_Uses_Consistent_Options_Snapshot()
|
|
{
|
|
var a = new DriverResilienceOptions { Tier = DriverTier.A };
|
|
var b = new DriverResilienceOptions { Tier = DriverTier.B };
|
|
var alternating = new[] { a, b, a, b }.AsEnumerable().GetEnumerator();
|
|
var invoker = new CapabilityInvoker(
|
|
new DriverResiliencePipelineBuilder(),
|
|
"drv-test",
|
|
() => { alternating.MoveNext(); return alternating.Current; });
|
|
|
|
// If options is read twice, the with-expression and Resolve(Write) come from
|
|
// different tier tables (A then B) — the resulting one-entry dictionary is
|
|
// inconsistent with the snapshot used for the rest of the options. Single-snapshot
|
|
// semantics guarantee the call sees a coherent view.
|
|
await Should.NotThrowAsync(async () => await invoker.ExecuteWriteAsync(
|
|
"host-1",
|
|
isIdempotent: false,
|
|
_ => ValueTask.FromResult(0),
|
|
CancellationToken.None));
|
|
}
|
|
}
|