Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/CapabilityInvokerTests.cs
T

324 lines
13 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);
/// <summary>Verifies that the capability invoker returns the value from the call site.</summary>
[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);
}
/// <summary>Verifies that the capability invoker retries on transient failures.</summary>
[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);
}
/// <summary>Verifies that non-idempotent writes do not retry even when the policy has retries configured.</summary>
[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");
}
/// <summary>Verifies that idempotent writes retry when the policy has retries configured.</summary>
[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);
}
/// <summary>Verifies that writes do not retry when the policy has zero retries configured.</summary>
[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");
}
/// <summary>Verifies that different hosts are honored independently in the resilience pipeline.</summary>
[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>
/// S-8 residual: the non-idempotent write arm must record tracker in-flight accounting (start +
/// complete) on the caller's host, exactly like the idempotent arms — the previous arm skipped it.
/// </summary>
[Fact]
public async Task NonIdempotentWrite_RecordsTrackerStartAndComplete()
{
var tracker = new DriverResilienceStatusTracker();
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-test",
() => new DriverResilienceOptions { Tier = DriverTier.A },
statusTracker: tracker);
await invoker.ExecuteWriteAsync(
"host-1",
isIdempotent: false,
_ => ValueTask.FromResult(0),
CancellationToken.None);
var snap = tracker.TryGet("drv-test", "host-1");
snap.ShouldNotBeNull("the non-idempotent write arm must record tracker accounting on the caller's host");
snap!.CurrentInFlight.ShouldBe(0, "in-flight must return to 0 after the write completes");
}
/// <summary>
/// 01/P-4: the non-idempotent write arm must build the no-retry options snapshot ONCE per invoker
/// lifetime (not per call). Two writes to the same host reuse the cached snapshot — the options
/// accessor is invoked at most once and only one pipeline is cached.
/// </summary>
[Fact]
public async Task NonIdempotentWrite_ReusesCachedNoRetryOptions()
{
var builder = new DriverResiliencePipelineBuilder();
var accessorCalls = 0;
var invoker = new CapabilityInvoker(
builder,
"drv-test",
() => { Interlocked.Increment(ref accessorCalls); return new DriverResilienceOptions { Tier = DriverTier.A }; });
await invoker.ExecuteWriteAsync("host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None);
await invoker.ExecuteWriteAsync("host-1", isIdempotent: false, _ => ValueTask.FromResult(0), CancellationToken.None);
accessorCalls.ShouldBeLessThanOrEqualTo(1, "the no-retry snapshot must be cached across calls");
builder.CachedPipelineCount.ShouldBe(1, "two writes to the same host share one cached no-retry pipeline");
}
/// <summary>
/// R2-10: a successful capability call must reset <c>ConsecutiveFailures</c> so the counter
/// is a true "consecutive" gauge — nonzero only during an active retry storm, not stuck at the
/// last blip's count forever. Previously only the breaker's <c>OnClosed</c> reset it, so a
/// transient 2-retry blip that never opened the breaker read "2 failures" indefinitely.
/// </summary>
[Fact]
public async Task ExecuteAsync_OnSuccess_ResetsConsecutiveFailures()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow);
tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(2, "seeded failures");
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-test",
() => new DriverResilienceOptions { Tier = DriverTier.A },
statusTracker: tracker);
await invoker.ExecuteAsync(
DriverCapability.Read,
"host-1",
_ => ValueTask.FromResult(1),
CancellationToken.None);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(0,
"a successful capability call must clear the consecutive-failure counter");
}
/// <summary>
/// R2-10 companion: a successful non-idempotent write must also reset the consecutive-failure
/// counter on the caller's host (the write arm has its own success path).
/// </summary>
[Fact]
public async Task ExecuteWriteAsync_NonIdempotent_OnSuccess_ResetsConsecutiveFailures()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-test", "host-1", DateTime.UtcNow);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(1, "seeded failure");
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-test",
() => new DriverResilienceOptions { Tier = DriverTier.A },
statusTracker: tracker);
await invoker.ExecuteWriteAsync(
"host-1",
isIdempotent: false,
_ => ValueTask.FromResult(0),
CancellationToken.None);
tracker.TryGet("drv-test", "host-1")!.ConsecutiveFailures.ShouldBe(0,
"a successful non-idempotent write must clear the consecutive-failure counter");
}
/// <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));
}
}