Auto: abcip-4.1 — per-tag scan rate / scan group bucketing

Closes #238
This commit is contained in:
Joseph Doherty
2026-04-26 02:15:50 -04:00
parent e5c38a5a0e
commit b45713622f
8 changed files with 761 additions and 7 deletions

View File

@@ -0,0 +1,90 @@
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.IntegrationTests;
/// <summary>
/// PR abcip-4.1 — end-to-end cadence smoke for per-tag <see cref="AbCipTagDefinition.ScanRateMs"/>
/// bucketing against a live <c>ab_server</c>. Drives two tags pointed at the same seeded
/// <c>TestDINT</c> at 100 ms / 1000 ms ScanRate and asserts the faster bucket receives
/// substantially more <c>OnDataChange</c> notifications than the slower one over a
/// 1.2 s window. Skipped when <c>ab_server</c> isn't reachable, same gating rule as
/// <see cref="AbCipReadSmokeTests"/>.
/// </summary>
/// <remarks>
/// The fake-driver unit test (<c>AbCipPerTagScanRateTests.Faster_bucket_publishes_more_frequently_than_slower_bucket</c>)
/// covers the bucketing math against an in-process fake. This test exercises the
/// full libplctag stack so a regression in how the driver wires its multi-bucket
/// poll engines to the real wire path shows up here. The two declared tags share
/// one underlying PLC tag (<c>TestDINT</c>) so the cadence assertion isolates the
/// polling-rate plumbing from PLC-side state changes.
/// </remarks>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipPerTagScanRateTests
{
[AbServerFact]
public async Task Faster_tag_publishes_more_often_than_slower_tag_against_ab_server()
{
var profile = KnownProfiles.ControlLogix;
var fixture = new AbServerFixture(profile);
await fixture.InitializeAsync();
try
{
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
Tags =
[
// Two distinct OPC UA tag references, both backed by the same PLC symbol.
new AbCipTagDefinition("FastCounter", deviceUri, "TestDINT", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("SlowCounter", deviceUri, "TestDINT", AbCipDataType.DInt, ScanRateMs: 1000),
],
Timeout = TimeSpan.FromSeconds(5),
}, "drv-scan-rate-smoke");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var fastEvents = 0;
var slowEvents = 0;
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == "FastCounter") Interlocked.Increment(ref fastEvents);
else if (e.FullReference == "SlowCounter") Interlocked.Increment(ref slowEvents);
};
var handle = await drv.SubscribeAsync(
["FastCounter", "SlowCounter"],
TimeSpan.FromMilliseconds(500),
TestContext.Current.CancellationToken);
// Bucket-count assertion runs against the real driver too — proves the partition
// logic is wired identically in production code paths, not just in unit-test stubs.
drv.GetSubscriptionBucketCount(handle).ShouldBe(2,
"two distinct ScanRateMs values must produce two real PollGroupEngine subscriptions");
await Task.Delay(TimeSpan.FromMilliseconds(1200));
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
// PollGroupEngine only fires OnDataChange when the boxed value differs from the
// last seen snapshot, so on a stable PLC value (TestDINT not being driven in this
// test) we expect ~1 event per tag (initial-data push). To make the cadence
// assertion meaningful even when ab_server's TestDINT is idle, demand that the
// *fast* tag fires at least once (proving the 100 ms bucket ticked). The
// unit-test cadence assertion handles the >4x ratio with a forced-change fake.
fastEvents.ShouldBeGreaterThan(0,
"fast tag must receive at least the initial-data push event");
slowEvents.ShouldBeGreaterThan(0,
"slow tag must receive at least the initial-data push event");
}
finally
{
await fixture.DisposeAsync();
}
}
}

View File

@@ -0,0 +1,312 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// PR abcip-4.1 — unit coverage for per-tag <see cref="AbCipTagDefinition.ScanRateMs"/>
/// bucketing in <see cref="AbCipDriver.SubscribeAsync"/>. Each test wires a small tag map
/// and asserts the driver spins up exactly the expected number of internal poll-engine
/// subscriptions by inspecting the test-only <c>GetSubscriptionBucketCount</c> entry
/// point. End-to-end tick cadence is covered by
/// <c>AbCipPerTagScanRateTests</c> in the IntegrationTests project.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipPerTagScanRateTests
{
private static AbCipDriver NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = 0 },
};
return new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
}, "drv-scan", factory);
}
[Fact]
public async Task Two_tags_with_distinct_ScanRate_produce_two_buckets()
{
var drv = NewDriver(
new AbCipTagDefinition("Fast", "ab://10.0.0.5/1,0", "Fast", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt, ScanRateMs: 1000));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Fast", "Slow"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(2,
"two distinct ScanRateMs values must produce two separate poll-engine subscriptions");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Tag_without_ScanRate_uses_subscription_default()
{
var drv = NewDriver(
new AbCipTagDefinition("PlainA", "ab://10.0.0.5/1,0", "PlainA", AbCipDataType.DInt),
new AbCipTagDefinition("PlainB", "ab://10.0.0.5/1,0", "PlainB", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["PlainA", "PlainB"], TimeSpan.FromMilliseconds(750), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"no ScanRateMs configured → single default bucket (legacy behaviour)");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Tag_with_ScanRate_equal_to_default_collapses_into_default_bucket()
{
// The driver buckets by resolved TimeSpan, not by "did the tag declare a rate" — a tag
// whose ScanRateMs matches the subscription default lands in the same bucket as plain
// tags. This avoids fragmenting the poll loop when the override is redundant.
var drv = NewDriver(
new AbCipTagDefinition("Plain", "ab://10.0.0.5/1,0", "Plain", AbCipDataType.DInt),
new AbCipTagDefinition("Match", "ab://10.0.0.5/1,0", "Match", AbCipDataType.DInt, ScanRateMs: 500));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Plain", "Match"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"ScanRateMs=500 with default=500ms must collapse into the same bucket");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task All_tags_at_same_explicit_rate_produce_one_bucket()
{
var drv = NewDriver(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt, ScanRateMs: 250),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, ScanRateMs: 250),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt, ScanRateMs: 250));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"three tags at the same ScanRateMs share one bucket");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Three_distinct_rates_produce_three_buckets()
{
var drv = NewDriver(
new AbCipTagDefinition("Fast", "ab://10.0.0.5/1,0", "Fast", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Medium", "ab://10.0.0.5/1,0", "Medium", AbCipDataType.DInt, ScanRateMs: 500),
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt, ScanRateMs: 5000));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Fast", "Medium", "Slow"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(3);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Mixed_overrides_and_default_produce_correct_bucket_count()
{
// Two fast (100 ms), three default (=1000 ms): 2 buckets.
var drv = NewDriver(
new AbCipTagDefinition("Hmi1", "ab://10.0.0.5/1,0", "Hmi1", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Hmi2", "ab://10.0.0.5/1,0", "Hmi2", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Slow1", "ab://10.0.0.5/1,0", "Slow1", AbCipDataType.DInt),
new AbCipTagDefinition("Slow2", "ab://10.0.0.5/1,0", "Slow2", AbCipDataType.DInt),
new AbCipTagDefinition("Slow3", "ab://10.0.0.5/1,0", "Slow3", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(
["Hmi1", "Hmi2", "Slow1", "Slow2", "Slow3"],
TimeSpan.FromMilliseconds(1000),
CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(2);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Test fake whose <see cref="DecodeValue"/> returns a strictly-monotonic counter so
/// every poll registers as a "change" against <see cref="PollGroupEngine"/>'s
/// boxed-equality diff. Lets us count actual poll cadence end-to-end.
/// </summary>
private sealed class CountingFakeTag : FakeAbCipTag
{
private int _readCount;
public CountingFakeTag(AbCipTagCreateParams p) : base(p) { }
public override Task ReadAsync(CancellationToken ct)
{
Interlocked.Increment(ref _readCount);
return base.ReadAsync(ct);
}
public override object? DecodeValue(AbCipDataType type, int? bitIndex) => _readCount;
}
[Fact]
public async Task Faster_bucket_publishes_more_frequently_than_slower_bucket()
{
// End-to-end timing assertion against an in-process fake: a 100 ms tag must
// accumulate substantially more DataChange events than a 1000 ms tag over a 1.2 s
// window. We force every poll to register as a *change* by returning a strictly
// monotonic counter (CountingFakeTag) so the diff path doesn't suppress events.
var factory = new FakeAbCipTagFactory
{
Customise = p => new CountingFakeTag(p),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Fast", "ab://10.0.0.5/1,0", "Fast", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt, ScanRateMs: 1000),
],
}, "drv-scan-cadence", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var fastEvents = 0;
var slowEvents = 0;
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == "Fast") Interlocked.Increment(ref fastEvents);
else if (e.FullReference == "Slow") Interlocked.Increment(ref slowEvents);
};
var handle = await drv.SubscribeAsync(
["Fast", "Slow"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
await Task.Delay(TimeSpan.FromMilliseconds(1200));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
// Fast at 100 ms: ~12 polls in 1.2 s. Slow at 1000 ms: ~2 polls (initial + 1 tick).
// Demand at least 4x — generous against jitter, definitive enough to prove buckets
// tick independently.
fastEvents.ShouldBeGreaterThan(slowEvents * 4,
$"fast bucket ({fastEvents} events) must outpace slow bucket ({slowEvents} events) by >4x");
}
[Fact]
public async Task Unsubscribe_disposes_every_bucket_subscription()
{
// Multi-bucket unsubscribe must not leak any inner poll-engine handle. We assert this
// by checking the post-unsubscribe bucket count is zero (composite handle purged) and
// by counting the engine's ActiveSubscriptionCount via reflection.
var drv = NewDriver(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt, ScanRateMs: 100),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, ScanRateMs: 200),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt, ScanRateMs: 500));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(3);
var pollField = typeof(AbCipDriver).GetField("_poll",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var poll = (PollGroupEngine)pollField!.GetValue(drv)!;
poll.ActiveSubscriptionCount.ShouldBe(3, "three buckets → three live engine subscriptions");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
poll.ActiveSubscriptionCount.ShouldBe(0, "every bucket subscription must be torn down on unsubscribe");
// Idempotent: a second unsubscribe must be a no-op, not throw.
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public void Dto_round_trip_preserves_ScanRateMs()
{
// The DTO surface must round-trip ScanRateMs through JSON so a config file edited via
// the Admin UI or hand-written JSON keeps the per-tag rate intact across redeploys.
const string configJson = """
{
"Devices": [ { "HostAddress": "ab://10.0.0.5/1,0", "PlcFamily": "ControlLogix" } ],
"Tags": [
{ "Name": "Fast", "DeviceHostAddress": "ab://10.0.0.5/1,0", "TagPath": "Fast", "DataType": "DInt", "ScanRateMs": 100 },
{ "Name": "Slow", "DeviceHostAddress": "ab://10.0.0.5/1,0", "TagPath": "Slow", "DataType": "DInt", "ScanRateMs": 1000 },
{ "Name": "Plain", "DeviceHostAddress": "ab://10.0.0.5/1,0", "TagPath": "Plain", "DataType": "DInt" }
]
}
""";
var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-roundtrip", configJson);
// Reach into the driver's options via reflection to check the Tag definitions hold ScanRateMs.
var optionsField = typeof(AbCipDriver).GetField("_options",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var opts = (AbCipDriverOptions)optionsField!.GetValue(driver)!;
opts.Tags.Single(t => t.Name == "Fast").ScanRateMs.ShouldBe(100);
opts.Tags.Single(t => t.Name == "Slow").ScanRateMs.ShouldBe(1000);
opts.Tags.Single(t => t.Name == "Plain").ScanRateMs.ShouldBeNull();
}
[Fact]
public async Task Negative_or_zero_ScanRate_is_treated_as_unset()
{
// Mis-typed config (ScanRateMs: 0 or -1) must NOT crash the driver — fall back to the
// subscription default. Same "config typo degrades" stance the factory layer takes.
var drv = NewDriver(
new AbCipTagDefinition("Zero", "ab://10.0.0.5/1,0", "Zero", AbCipDataType.DInt, ScanRateMs: 0),
new AbCipTagDefinition("Neg", "ab://10.0.0.5/1,0", "Neg", AbCipDataType.DInt, ScanRateMs: -1),
new AbCipTagDefinition("Plain", "ab://10.0.0.5/1,0", "Plain", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["Zero", "Neg", "Plain"], TimeSpan.FromMilliseconds(500), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1,
"0 + negative ScanRateMs must collapse into the subscription default bucket");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Udt_member_inherits_parent_ScanRate()
{
// The driver fans out UDT members at InitializeAsync — each member tag inherits the
// parent's ScanRateMs so an entire UDT subscribed at 100 ms publishes coherently.
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { Value = 0 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition(
"Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)],
ScanRateMs: 250),
],
}, "drv-udt-inherit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Subscribing the member tag alone must use the parent's 250 ms rate, not the default.
var handle = await drv.SubscribeAsync(["Motor1.Speed"], TimeSpan.FromMilliseconds(1000), CancellationToken.None);
drv.GetSubscriptionBucketCount(handle).ShouldBe(1);
drv.ResolveTagInterval("Motor1.Speed", TimeSpan.FromMilliseconds(1000))
.ShouldBe(TimeSpan.FromMilliseconds(250));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
}