feat(r2-04): meter unconfirmed + advise-failed Galaxy writes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user