From 404bfbe7e4c55e12d9b77cd4de6427ffd5a16212 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 23:07:37 -0400 Subject: [PATCH] =?UTF-8?q?AlarmSurfaceInvoker=20=E2=80=94=20wraps=20IAlar?= =?UTF-8?q?mSource.Subscribe/Unsubscribe/Acknowledge=20through=20Capabilit?= =?UTF-8?q?yInvoker=20with=20multi-host=20fan-out.=20Closes=20alarm-surfac?= =?UTF-8?q?e=20slice=20of=20task=20#161=20(Phase=206.1=20Stream=20A);=20th?= =?UTF-8?q?e=20Roslyn=20invoker-coverage=20analyzer=20is=20split=20into=20?= =?UTF-8?q?new=20task=20#200=20because=20a=20DiagnosticAnalyzer=20project?= =?UTF-8?q?=20is=20genuinely=20its=20own=20scaffolding=20PR=20(Microsoft.C?= =?UTF-8?q?odeAnalysis.CSharp.Workspaces=20dep,=20netstandard2.0=20target,?= =?UTF-8?q?=20Microsoft.CodeAnalysis.Testing=20harness,=20ProjectReference?= =?UTF-8?q?=20OutputItemType=3DAnalyzer=20wiring,=20and=20four=20corner-ca?= =?UTF-8?q?se=20rules=20I=20want=20tests=20for=20before=20shipping).=20Shi?= =?UTF-8?q?p=20this=20PR=20as=20the=20runtime=20guardrail=20+=20callable?= =?UTF-8?q?=20API;=20the=20analyzer=20lands=20next=20as=20the=20compile-ti?= =?UTF-8?q?me=20guardrail.=20New=20AlarmSurfaceInvoker=20class=20in=20Core?= =?UTF-8?q?.Resilience.=20Three=20methods=20mirror=20IAlarmSource's=20thre?= =?UTF-8?q?e=20mutating=20surfaces:=20SubscribeAsync=20(fan-out:=20group?= =?UTF-8?q?=20sourceNodeIds=20by=20IPerCallHostResolver.ResolveHost,=20one?= =?UTF-8?q?=20CapabilityInvoker.ExecuteAsync=20per=20host=20with=20DriverC?= =?UTF-8?q?apability.AlarmSubscribe=20so=20AlarmSubscribe's=20retry=20poli?= =?UTF-8?q?cy=20kicks=20in=20+=20returns=20one=20IAlarmSubscriptionHandle?= =?UTF-8?q?=20per=20host);=20UnsubscribeAsync=20(single-host,=20defaultHos?= =?UTF-8?q?t);=20AcknowledgeAsync=20(fan-out:=20group=20AlarmAcknowledgeRe?= =?UTF-8?q?quests=20by=20resolver-mapped=20host,=20run=20each=20host's=20b?= =?UTF-8?q?atch=20through=20DriverCapability.AlarmAcknowledge=20which=20do?= =?UTF-8?q?es=20NOT=20retry=20per=20decision=20#143=20=E2=80=94=20alarm-ac?= =?UTF-8?q?k=20is=20a=20write-shaped=20op=20that's=20not=20idempotent=20at?= =?UTF-8?q?=20the=20plant-floor=20level).=20Drivers=20without=20IPerCallHo?= =?UTF-8?q?stResolver=20(Galaxy=20single=20MXAccess=20endpoint,=20OpcUaCli?= =?UTF-8?q?ent=20against=20one=20remote,=20etc.)=20fall=20back=20to=20defa?= =?UTF-8?q?ultHost=20=3D=20DriverInstanceId=20so=20breaker=20+=20bulkhead?= =?UTF-8?q?=20keying=20still=20happens;=20drivers=20with=20it=20get=20one-?= =?UTF-8?q?dead-PLC-doesn't-poison-siblings=20isolation=20per=20decision?= =?UTF-8?q?=20#144.=20Single-host=20single-subscribe=20returns=20[handle]?= =?UTF-8?q?=20with=20length=201;=20empty=20sourceNodeIds=20fast-paths=20to?= =?UTF-8?q?=20[]=20without=20a=20driver=20call.=20Five=20new=20AlarmSurfac?= =?UTF-8?q?eInvokerTests=20covering:=20(a)=20empty=20list=20short-circuits?= =?UTF-8?q?=20=E2=80=94=20driver=20method=20never=20called;=20(b)=20single?= =?UTF-8?q?-host=20sub=20routes=20via=20default=20host=20=E2=80=94=20one?= =?UTF-8?q?=20driver=20call=20with=20full=20id=20list;=20(c)=20multi-host?= =?UTF-8?q?=20sub=20fans=20out=20to=202=20distinct=20hosts=20for=203=20src?= =?UTF-8?q?=20ids=20mapping=20to=202=20plcs=20=E2=80=94=20one=20driver=20c?= =?UTF-8?q?all=20per=20host;=20(d)=20Acknowledge=20does=20not=20retry=20on?= =?UTF-8?q?=20failure=20=E2=80=94=20call=20count=20stays=20at=201=20even?= =?UTF-8?q?=20with=20exception;=20(e)=20Subscribe=20retries=20transient=20?= =?UTF-8?q?failures=20=E2=80=94=20call=20count=20reaches=203=20with=20a=20?= =?UTF-8?q?2-failures-then-success=20fake.=20Core.Tests=20resilience-build?= =?UTF-8?q?er=20suite=2019/19=20passing=20(was=2014,=20+5);=20Core.Tests?= =?UTF-8?q?=20whole=20suite=20still=20green.=20Core=20project=20builds=200?= =?UTF-8?q?=20errors.=20Task=20#200=20captures=20the=20compile-time=20guar?= =?UTF-8?q?drail:=20Roslyn=20DiagnosticAnalyzer=20at=20src/ZB.MOM.WW.OtOpc?= =?UTF-8?q?Ua.Analyzers=20that=20flags=20direct=20invocations=20of=20the?= =?UTF-8?q?=20eleven=20capability-interface=20methods=20inside=20the=20Ser?= =?UTF-8?q?ver=20namespace=20when=20the=20call=20is=20NOT=20inside=20a=20C?= =?UTF-8?q?apabilityInvoker.ExecuteAsync/ExecuteWriteAsync/AlarmSurfaceInv?= =?UTF-8?q?oker.*Async=20lambda.=20That=20analyzer=20is=20the=20reason=20w?= =?UTF-8?q?e=20keep=20paying=20the=20wrapping-class=20overhead=20for=20eve?= =?UTF-8?q?ry=20new=20capability.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Resilience/AlarmSurfaceInvoker.cs | 129 ++++++++++++++++++ .../Resilience/AlarmSurfaceInvokerTests.cs | 127 +++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/AlarmSurfaceInvokerTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs new file mode 100644 index 0000000..34ed86d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs @@ -0,0 +1,129 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Resilience; + +/// +/// Wraps the three mutating surfaces of +/// (, , +/// ) through so the +/// Phase 6.1 resilience pipeline runs — retry semantics match +/// (retries by default) and +/// (does NOT retry per decision #143). +/// +/// +/// Multi-host dispatch: when the driver implements , +/// each source-node-id is resolved individually + grouped by host so a dead PLC inside a +/// multi-device driver doesn't poison the sibling hosts' breakers. Drivers with a single +/// host fall back to as the single-host key. +/// +/// Why this lives here + not on : alarm surfaces have a +/// handle-returning shape (SubscribeAlarmsAsync returns ) +/// + a per-call fan-out (AcknowledgeAsync gets a batch of +/// s that may span multiple hosts). Keeping the fan-out +/// logic here keeps the invoker's execute-overloads narrow. +/// +public sealed class AlarmSurfaceInvoker +{ + private readonly CapabilityInvoker _invoker; + private readonly IAlarmSource _alarmSource; + private readonly IPerCallHostResolver? _hostResolver; + private readonly string _defaultHost; + + public AlarmSurfaceInvoker( + CapabilityInvoker invoker, + IAlarmSource alarmSource, + string defaultHost, + IPerCallHostResolver? hostResolver = null) + { + ArgumentNullException.ThrowIfNull(invoker); + ArgumentNullException.ThrowIfNull(alarmSource); + ArgumentException.ThrowIfNullOrWhiteSpace(defaultHost); + + _invoker = invoker; + _alarmSource = alarmSource; + _defaultHost = defaultHost; + _hostResolver = hostResolver; + } + + /// + /// Subscribe to alarm events for a set of source node ids, fanning out by resolved host + /// so per-host breakers / bulkheads apply. Returns one handle per host — callers that + /// don't care about per-host separation may concatenate them. + /// + public async Task> SubscribeAsync( + IReadOnlyList sourceNodeIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceNodeIds); + if (sourceNodeIds.Count == 0) return []; + + var byHost = GroupByHost(sourceNodeIds); + var handles = new List(byHost.Count); + foreach (var (host, ids) in byHost) + { + var handle = await _invoker.ExecuteAsync( + DriverCapability.AlarmSubscribe, + host, + async ct => await _alarmSource.SubscribeAlarmsAsync(ids, ct).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + handles.Add(handle); + } + return handles; + } + + /// Cancel an alarm subscription. Routes through the AlarmSubscribe pipeline for parity. + public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(handle); + return _invoker.ExecuteAsync( + DriverCapability.AlarmSubscribe, + _defaultHost, + async ct => await _alarmSource.UnsubscribeAlarmsAsync(handle, ct).ConfigureAwait(false), + cancellationToken); + } + + /// + /// Acknowledge alarms. Fans out by resolved host; each host's batch runs through the + /// AlarmAcknowledge pipeline (no-retry per decision #143 — an alarm-ack is not idempotent + /// at the plant-floor acknowledgement level even if the OPC UA spec permits re-issue). + /// + public async Task AcknowledgeAsync( + IReadOnlyList acknowledgements, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(acknowledgements); + if (acknowledgements.Count == 0) return; + + var byHost = _hostResolver is null + ? new Dictionary> { [_defaultHost] = acknowledgements.ToList() } + : acknowledgements + .GroupBy(a => _hostResolver.ResolveHost(a.SourceNodeId)) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var (host, batch) in byHost) + { + var batchSnapshot = batch; // capture for the lambda + await _invoker.ExecuteAsync( + DriverCapability.AlarmAcknowledge, + host, + async ct => await _alarmSource.AcknowledgeAsync(batchSnapshot, ct).ConfigureAwait(false), + cancellationToken).ConfigureAwait(false); + } + } + + private Dictionary> GroupByHost(IReadOnlyList sourceNodeIds) + { + if (_hostResolver is null) + return new Dictionary> { [_defaultHost] = sourceNodeIds.ToList() }; + + var result = new Dictionary>(StringComparer.Ordinal); + foreach (var id in sourceNodeIds) + { + var host = _hostResolver.ResolveHost(id); + if (!result.TryGetValue(host, out var list)) + result[host] = list = new List(); + list.Add(id); + } + return result; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/AlarmSurfaceInvokerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/AlarmSurfaceInvokerTests.cs new file mode 100644 index 0000000..6921ceb --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/AlarmSurfaceInvokerTests.cs @@ -0,0 +1,127 @@ +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 AlarmSurfaceInvokerTests +{ + private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A }; + + [Fact] + public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall() + { + var driver = new FakeAlarmSource(); + var surface = NewSurface(driver, defaultHost: "h"); + + var handles = await surface.SubscribeAsync([], CancellationToken.None); + + handles.Count.ShouldBe(0); + driver.SubscribeCallCount.ShouldBe(0); + } + + [Fact] + public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost() + { + var driver = new FakeAlarmSource(); + var surface = NewSurface(driver, defaultHost: "h1"); + + var handles = await surface.SubscribeAsync(["src-1", "src-2"], CancellationToken.None); + + handles.Count.ShouldBe(1); + driver.SubscribeCallCount.ShouldBe(1); + driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]); + } + + [Fact] + public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost() + { + var driver = new FakeAlarmSource(); + var resolver = new StubResolver(new Dictionary + { + ["src-1"] = "plc-a", + ["src-2"] = "plc-b", + ["src-3"] = "plc-a", + }); + var surface = NewSurface(driver, defaultHost: "default-ignored", resolver: resolver); + + var handles = await surface.SubscribeAsync(["src-1", "src-2", "src-3"], CancellationToken.None); + + handles.Count.ShouldBe(2); // one per distinct host + driver.SubscribeCallCount.ShouldBe(2); // one driver call per host + } + + [Fact] + public async Task AcknowledgeAsync_DoesNotRetry_OnFailure() + { + var driver = new FakeAlarmSource { AcknowledgeShouldThrow = true }; + var surface = NewSurface(driver, defaultHost: "h1"); + + await Should.ThrowAsync(() => + surface.AcknowledgeAsync([new AlarmAcknowledgeRequest("s", "c", null)], CancellationToken.None)); + + driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143"); + } + + [Fact] + public async Task SubscribeAsync_Retries_Transient_Failures() + { + var driver = new FakeAlarmSource { SubscribeFailuresBeforeSuccess = 2 }; + var surface = NewSurface(driver, defaultHost: "h1"); + + await surface.SubscribeAsync(["src"], CancellationToken.None); + + driver.SubscribeCallCount.ShouldBe(3, "AlarmSubscribe retries by default — decision #143"); + } + + private static AlarmSurfaceInvoker NewSurface( + IAlarmSource driver, + string defaultHost, + IPerCallHostResolver? resolver = null) + { + var builder = new DriverResiliencePipelineBuilder(); + var invoker = new CapabilityInvoker(builder, "drv-1", () => TierAOptions); + return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver); + } + + private sealed class FakeAlarmSource : IAlarmSource + { + public int SubscribeCallCount { get; private set; } + public int AcknowledgeCallCount { get; private set; } + public int SubscribeFailuresBeforeSuccess { get; set; } + public bool AcknowledgeShouldThrow { get; set; } + public IReadOnlyList LastSubscribedIds { get; private set; } = []; + + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + SubscribeCallCount++; + LastSubscribedIds = sourceNodeIds; + if (SubscribeCallCount <= SubscribeFailuresBeforeSuccess) + throw new InvalidOperationException("transient"); + return Task.FromResult(new StubHandle($"h-{SubscribeCallCount}")); + } + + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) + { + AcknowledgeCallCount++; + if (AcknowledgeShouldThrow) throw new InvalidOperationException("ack boom"); + return Task.CompletedTask; + } + + public event EventHandler? OnAlarmEvent { add { } remove { } } + } + + private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle; + + private sealed class StubResolver(Dictionary map) : IPerCallHostResolver + { + public string ResolveHost(string fullReference) => map[fullReference]; + } +} -- 2.49.1