From 084c6e3ceeb59575b83f9a7192ce22b7db00125d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:53:17 -0400 Subject: [PATCH] feat(r2-04): meter unconfirmed + advise-failed Galaxy writes --- ...2-04-failure-visibility-plan.md.tasks.json | 2 +- .../Runtime/GatewayGalaxyDataWriter.cs | 22 +++++ .../GatewayGalaxyDataWriterFailClosedTests.cs | 91 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json b/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json index 9e7cacfd..260ece96 100644 --- a/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json +++ b/archreview/plans/R2-04-failure-visibility-plan.md.tasks.json @@ -8,7 +8,7 @@ { "id": "T4", "subject": "OpcUaApplyFailed counter + OpcUaPublishActor Error/meter surfacing of degraded applies", "status": "completed", "blockedBy": ["T3"] }, { "id": "T5", "subject": "Galaxy internal IMxWriteOps seam under GatewayGalaxyDataWriter (sealed SDK session untestable) (06/S-1)", "status": "completed", "blockedBy": [] }, { "id": "T6", "subject": "Galaxy fail-closed write on AdviseSupervisory failure -> BadCommunicationError, no raw write", "status": "completed", "blockedBy": ["T5"] }, - { "id": "T7", "subject": "Galaxy counters galaxy.writes.advise_failed + galaxy.writes.unconfirmed (empty-statuses Good metered)", "status": "pending", "blockedBy": ["T6"] }, + { "id": "T7", "subject": "Galaxy counters galaxy.writes.advise_failed + galaxy.writes.unconfirmed (empty-statuses Good metered)", "status": "completed", "blockedBy": ["T6"] }, { "id": "T8", "subject": "Galaxy skip-gated live smoke: normal advised write still returns Good post-change", "status": "pending", "blockedBy": ["T6"] }, { "id": "T9", "subject": "PrimaryGatePolicy pure class + truth-table tests + otopcua.redundancy.primary_gate_denied counter (03/S4)", "status": "pending", "blockedBy": [] }, { "id": "T10", "subject": "Wire DriverHostActor gates (:977/:1039/:1095) through PrimaryGatePolicy with member-count provider + S5 log-once", "status": "pending", "blockedBy": ["T9"] }, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs index 7374ec12..282803ec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics.Metrics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Client; @@ -37,6 +38,18 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter private readonly ConcurrentDictionary _supervisedHandles = new(); private int _addItemCallCount; + // Write failure-visibility meters (archreview 06/S-1). Reuse the pump's meter name so the single host + // listener catches both. advise_failed = writes short-circuited to Bad because AdviseSupervisory failed + // (the value could not have committed); unconfirmed = writes reported Good off an EMPTY gateway statuses + // array (command accepted, COM commit unconfirmed pending the gateway WriteComplete correlation follow-up). + private static readonly Meter WriterMeter = new(EventPump.MeterName); + private static readonly Counter WriteAdviseFailed = + WriterMeter.CreateCounter("galaxy.writes.advise_failed", unit: "{write}", + description: "Writes short-circuited to Bad because AdviseSupervisory failed (value could not have committed)."); + private static readonly Counter WriteUnconfirmed = + WriterMeter.CreateCounter("galaxy.writes.unconfirmed", unit: "{write}", + description: "Writes reported Good off an EMPTY gateway statuses array (command accepted; commit unconfirmed)."); + /// Initializes a new Galaxy data writer. /// The MXAccess gateway session. /// The user ID for write operations. @@ -186,6 +199,7 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter // the galaxy.writes.* meters are the honest signal. if (!await EnsureSupervisoryAdvisedAsync(ops, itemHandle, ct).ConfigureAwait(false)) { + WriteAdviseFailed.Add(1); return new WriteResult(StatusCodeMap.BadCommunicationError); } reply = await ops.WriteRawAsync(itemHandle, mxValue, _writeUserId, ct) @@ -310,6 +324,11 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter /// Translate a gateway into an OPC UA /// . Honours the protocol-level Status field first /// (transport / dispatch failures), then the first MXAccess status row. + /// An EMPTY statuses array is treated as Good but is PROVISIONAL: the reply proves + /// worker-side command acceptance, not the COM-side commit (the gateway does not yet correlate a + /// WriteComplete row onto the unary reply — mxaccessgw backlog). Each such reply increments + /// galaxy.writes.unconfirmed so the unconfirmed-write rate is operator-visible today + /// (archreview 06/S-1). /// private WriteResult TranslateReply(MxCommandReply reply, string fullRef) { @@ -331,6 +350,9 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter return new WriteResult(StatusCodeMap.FromMxStatus(status, _logger)); } + // Empty statuses ⇒ provisional Good. Meter it so the unconfirmed-write rate is visible until the + // gateway-side WriteComplete correlation lands (archreview 06/S-1 cross-repo follow-up). + WriteUnconfirmed.Add(1); return new WriteResult(StatusCodeMap.Good); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs index fe4eea7b..5b514fba 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterFailClosedTests.cs @@ -81,8 +81,99 @@ public sealed class GatewayGalaxyDataWriterFailClosedTests writer.CachedSupervisedHandleCount.ShouldBe(1); } + // ---------------- T7: counters ---------------- + + /// A fail-closed advise increments galaxy.writes.advise_failed (and not unconfirmed). + [Fact] + public async Task WriteOne_AdviseFails_Increments_AdviseFailed_Meter() + { + using var advise = new MeterRecorder("galaxy.writes.advise_failed"); + using var unconfirmed = new MeterRecorder("galaxy.writes.unconfirmed"); + var ops = new FakeMxWriteOps + { + InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure } }, + }; + var writer = NewWriter(); + + await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.A", true), SecurityClassification.FreeAccess, CancellationToken.None); + + advise.Total.ShouldBe(1); + unconfirmed.Total.ShouldBe(0); + } + + /// An advised write whose reply carries an EMPTY statuses array returns Good but increments + /// galaxy.writes.unconfirmed (the commit is unconfirmed pending gateway WriteComplete correlation). + [Fact] + public async Task WriteOne_EmptyStatuses_ReturnsGood_And_Increments_Unconfirmed_Meter() + { + using var advise = new MeterRecorder("galaxy.writes.advise_failed"); + using var unconfirmed = new MeterRecorder("galaxy.writes.unconfirmed"); + var ops = new FakeMxWriteOps + { + InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }, + RawWriteResponder = () => new MxCommandReply(), // no ProtocolStatus, EMPTY statuses ⇒ Good + }; + var writer = NewWriter(); + + var result = await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.B", 1), SecurityClassification.FreeAccess, CancellationToken.None); + + result.StatusCode.ShouldBe(StatusCodeMap.Good); + unconfirmed.Total.ShouldBe(1); + advise.Total.ShouldBe(0); + } + + /// A reply carrying an MX status ROW increments neither meter (not empty-statuses, advise OK). + [Fact] + public async Task WriteOne_StatusRowReply_Increments_Neither_Meter() + { + using var advise = new MeterRecorder("galaxy.writes.advise_failed"); + using var unconfirmed = new MeterRecorder("galaxy.writes.unconfirmed"); + var okStatusReply = new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }; + okStatusReply.Statuses.Add(new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok }); + var ops = new FakeMxWriteOps + { + InvokeResponder = _ => new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } }, + RawWriteResponder = () => okStatusReply, + }; + var writer = NewWriter(); + + var result = await writer.WriteOneForTestAsync(ops, new WriteRequest("Tag.C", 2), SecurityClassification.FreeAccess, CancellationToken.None); + + result.StatusCode.ShouldBe(StatusCodeMap.Good); // Success/Ok status row ⇒ Good + advise.Total.ShouldBe(0); + unconfirmed.Total.ShouldBe(0); // NOT empty-statuses ⇒ not metered unconfirmed + } + // ---------------- fakes ---------------- + /// Listens to a single instrument by name on the Galaxy driver meter and sums the values. + private sealed class MeterRecorder : IDisposable + { + private readonly string _name; + private readonly System.Diagnostics.Metrics.MeterListener _listener; + private long _total; + private readonly object _gate = new(); + + public MeterRecorder(string instrumentName) + { + _name = instrumentName; + _listener = new System.Diagnostics.Metrics.MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == EventPump.MeterName && instrument.Name == _name) + listener.EnableMeasurementEvents(instrument); + } + }; + _listener.SetMeasurementEventCallback((_, value, _, _) => { lock (_gate) _total += value; }); + _listener.Start(); + } + + public long Total { get { lock (_gate) return _total; } } + + public void Dispose() => _listener.Dispose(); + } + /// A fake that records every call and returns canned replies. private sealed class FakeMxWriteOps : IMxWriteOps {