fix(abcip): throttle evict-recreate Forward-Open attempts per device (05/STAB-8)
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 05/STAB-8 — a dead AbCip device must not re-create + re-initialize (Forward-Open) a libplctag
|
||||
/// runtime per tag per tick. The per-device <see cref="ConnectionBackoff"/> gates the
|
||||
/// runtime-create path; cache hits are unaffected; the probe loop (its own runtime) drives
|
||||
/// failure/success so a recovered device resets the window.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipConnectBackoffTests
|
||||
{
|
||||
private static AbCipDriverOptions Options(bool probeEnabled) => new()
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)],
|
||||
Probe = new AbCipProbeOptions
|
||||
{
|
||||
Enabled = probeEnabled,
|
||||
ProbeTagPath = "ProbeTag",
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Rapid reads against a dead device attempt a single Forward-Open, then fail fast inside the backoff window.</summary>
|
||||
[Fact]
|
||||
public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule()
|
||||
{
|
||||
var creates = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
Interlocked.Increment(ref creates);
|
||||
return new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new InvalidOperationException("down") };
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(Options(probeEnabled: false), "abcip-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
creates.ShouldBe(1); // one Forward-Open attempt; the rest fail fast inside the window
|
||||
}
|
||||
|
||||
/// <summary>A cached healthy runtime is read without ever consulting the backoff or re-creating.</summary>
|
||||
[Fact]
|
||||
public async Task CacheHit_NeverConsultsBackoff()
|
||||
{
|
||||
var creates = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
Interlocked.Increment(ref creates);
|
||||
return new FakeAbCipTag(p) { Value = 5 };
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(Options(probeEnabled: false), "abcip-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
creates.ShouldBe(1); // created once + cached; cache hits never re-create
|
||||
}
|
||||
|
||||
/// <summary>The probe bypasses the backoff window and a successful probe resets it so the next read succeeds.</summary>
|
||||
[Fact]
|
||||
public async Task ProbeBypassesBackoff_AndSuccessResets()
|
||||
{
|
||||
var fail = true;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbCipTag(p)
|
||||
{
|
||||
ThrowOnInitialize = fail,
|
||||
Exception = new InvalidOperationException("down"),
|
||||
Value = 7,
|
||||
},
|
||||
};
|
||||
var drv = new AbCipDriver(Options(probeEnabled: true), "abcip-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var down = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
down[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
|
||||
fail = false; // device recovers; the probe bypasses the window and resets it
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
|
||||
DataValueSnapshot? good = null;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
if (r[0].StatusCode == AbCipStatusMapper.Good) { good = r[0]; break; }
|
||||
await Task.Delay(25, CancellationToken.None);
|
||||
}
|
||||
|
||||
good.ShouldNotBeNull("probe-driven recovery must reset the backoff so reads recover");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user