feat(r2-04): meter unconfirmed + advise-failed Galaxy writes

This commit is contained in:
Joseph Doherty
2026-07-13 10:53:17 -04:00
parent 610306734c
commit 084c6e3cee
3 changed files with 114 additions and 1 deletions
@@ -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"] },
@@ -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<int, byte> _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<long> WriteAdviseFailed =
WriterMeter.CreateCounter<long>("galaxy.writes.advise_failed", unit: "{write}",
description: "Writes short-circuited to Bad because AdviseSupervisory failed (value could not have committed).");
private static readonly Counter<long> WriteUnconfirmed =
WriterMeter.CreateCounter<long>("galaxy.writes.unconfirmed", unit: "{write}",
description: "Writes reported Good off an EMPTY gateway statuses array (command accepted; commit unconfirmed).");
/// <summary>Initializes a new Galaxy data writer.</summary>
/// <param name="session">The MXAccess gateway session.</param>
/// <param name="writeUserId">The user ID for write operations.</param>
@@ -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 <see cref="MxCommandReply"/> into an OPC UA
/// <see cref="WriteResult"/>. Honours the protocol-level Status field first
/// (transport / dispatch failures), then the first MXAccess status row.
/// <para>An EMPTY statuses array is treated as <c>Good</c> 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
/// <c>galaxy.writes.unconfirmed</c> so the unconfirmed-write rate is operator-visible today
/// (archreview 06/S-1).</para>
/// </summary>
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);
}
}
@@ -81,8 +81,99 @@ public sealed class GatewayGalaxyDataWriterFailClosedTests
writer.CachedSupervisedHandleCount.ShouldBe(1);
}
// ---------------- T7: counters ----------------
/// <summary>A fail-closed advise increments galaxy.writes.advise_failed (and not unconfirmed).</summary>
[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);
}
/// <summary>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).</summary>
[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);
}
/// <summary>A reply carrying an MX status ROW increments neither meter (not empty-statuses, advise OK).</summary>
[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 ----------------
/// <summary>Listens to a single instrument by name on the Galaxy driver meter and sums the values.</summary>
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<long>((_, value, _, _) => { lock (_gate) _total += value; });
_listener.Start();
}
public long Total { get { lock (_gate) return _total; } }
public void Dispose() => _listener.Dispose();
}
/// <summary>A fake <see cref="IMxWriteOps"/> that records every call and returns canned replies.</summary>
private sealed class FakeMxWriteOps : IMxWriteOps
{