20a816dbf4
The mxaccessgw OnWriteComplete correlation is live (server b948e69, worker 53f69cd — all four unary write kinds), so every write reply's statuses[0] now carries the real COM commit outcome. Live-gating the cutover on windev found and fixed three defects: - StatusCodeMap.FromMxStatus truncated the correlated failure row's MxStatus detail code to a byte (1007 & 0xFF = 239 -> OPC DA Good band), mapping a REFUSED write to Good. New invariant: a row that is not IsSuccess() never maps Good — details > 0xFF (and contradictory Good-band substatuses) route by category (SECURITY_ERROR -> BadUserAccessDenied, named const covered by StatusCodeParityTests; COMMUNICATION/CONFIGURATION keep their codes). - The borrow-from-subscription-registry write optimization could never commit: a subscription handle is plain-advised, and MXAccess refuses both the no-login write through it (SECURITY_ERROR detail 1008, live-captured) and the supervisory advise upgrade (E_INVALIDARG) — one advise per HANDLE, not per attribute. Removed: writer subscribedHandleSource ctor param + borrow branch, GalaxyDriver wiring, SubscriptionRegistry.TryResolveItemHandle + its forward index and test file. The writer always AddItems its own supervisory-advised handle, which commits fine while the same attribute is subscribed (live-proven; pinned by the new write-while-subscribed smoke). - (gateway-side, fixed there) the correlation initially covered WriteSecured only, while the dominant no-login path emits plain Write. Live gate, all legs PASSED against windev: happy write Good off a confirmed status row with galaxy.writes.unconfirmed + advise_failed both FLAT; read-only refusal surfaces Bad-family (the #5 node-revert input Galaxy could never produce before); WriteCompletionWaitMilliseconds=0 control -> provisional Good + metered unconfirmed (override removed after, restoration re-verified). New env-gated smokes: Live_correlated_write_statuses_confirm_commit_and_surface_refusal, Live_write_to_subscribed_tag_commits_via_own_handle (replaces the borrow smoke), GatewayGalaxyWriteMeterLiveTests (run filtered — meters are process-wide).
134 lines
6.4 KiB
C#
134 lines
6.4 KiB
C#
using ZB.MOM.WW.MxGateway.Client;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
|
|
|
/// <summary>
|
|
/// Live meter legs for the 06/S-1 write-confirmation gate — the observation that closed the
|
|
/// archreview finding: against a correlated gateway (mxaccessgw server ≥ <c>b948e69</c>, worker ≥
|
|
/// <c>53f69cd</c>) every unary write reply carries the worker's <c>OnWriteComplete</c> outcome as
|
|
/// <c>statuses[0]</c>, so <c>galaxy.writes.unconfirmed</c> — which used to increment on EVERY
|
|
/// write — stays flat, and only a completion-wait timeout (or a pre-correlation gateway) meters.
|
|
/// <para>
|
|
/// ⚠️ Run these FILTERED (one test class), never as part of a full-assembly run with the env vars
|
|
/// set: the offline <see cref="GatewayGalaxyDataWriterTests"/> exercise the unconfirmed path
|
|
/// against fakes, and xunit runs test classes in parallel, so their increments would pollute the
|
|
/// process-wide counters these tests listen to.
|
|
/// </para>
|
|
/// </summary>
|
|
[Trait("Category", "Integration")]
|
|
public sealed class GatewayGalaxyWriteMeterLiveTests
|
|
{
|
|
private const string WriteRef = "TestMachine_002.TestFloat";
|
|
|
|
/// <summary>
|
|
/// Confirmed writes keep both write meters flat: five committed writes and one refusal, all
|
|
/// carrying a correlated status row — zero <c>unconfirmed</c> (the counter is presence-based,
|
|
/// so the refusal must not increment it either) and zero <c>advise_failed</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Live_confirmed_writes_keep_unconfirmed_meter_flat()
|
|
{
|
|
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
|
|
|
|
long unconfirmed = 0;
|
|
long adviseFailed = 0;
|
|
using var listener = new System.Diagnostics.Metrics.MeterListener();
|
|
listener.InstrumentPublished = (instrument, l) =>
|
|
{
|
|
if (instrument.Name is "galaxy.writes.unconfirmed" or "galaxy.writes.advise_failed")
|
|
l.EnableMeasurementEvents(instrument);
|
|
};
|
|
listener.SetMeasurementEventCallback<long>((instrument, value, _, _) =>
|
|
{
|
|
if (instrument.Name == "galaxy.writes.unconfirmed") Interlocked.Add(ref unconfirmed, value);
|
|
else Interlocked.Add(ref adviseFailed, value);
|
|
});
|
|
listener.Start();
|
|
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Meter"));
|
|
await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct);
|
|
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
var r = await writer.WriteAsync(
|
|
[new WriteRequest(WriteRef, 7000.0f + i)], _ => SecurityClassification.FreeAccess, ct);
|
|
Assert.Equal(0u, r[0].StatusCode);
|
|
}
|
|
var refused = await writer.WriteAsync(
|
|
[new WriteRequest("TestMachine_002.Tagname", "meter-leg-refusal")],
|
|
_ => SecurityClassification.FreeAccess, ct);
|
|
Assert.Equal(2u, (refused[0].StatusCode >> 30) & 0x3u);
|
|
|
|
listener.Dispose();
|
|
Assert.Equal(0, Interlocked.Read(ref unconfirmed));
|
|
Assert.Equal(0, Interlocked.Read(ref adviseFailed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Timeout-control leg — run ONLY while the target gateway has the completion wait disabled
|
|
/// (<c>MxGateway__Worker__WriteCompletionWaitMilliseconds=0</c>, an NSSM env-var flip +
|
|
/// service restart on a deployed instance): every reply is then empty-statuses, so the writer
|
|
/// reports provisional Good and meters each write unconfirmed. Gated on
|
|
/// <c>MXGW_WRITE_WAIT_DISABLED=1</c> so it can never run (and fail) against a
|
|
/// normally-configured gateway. Remove the override (same nssm command with <c>-</c>) and
|
|
/// restart afterwards; <see cref="Live_confirmed_writes_keep_unconfirmed_meter_flat"/>
|
|
/// passing again proves the restoration.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Live_wait_disabled_gateway_yields_provisional_good_and_metered_unconfirmed()
|
|
{
|
|
if (Environment.GetEnvironmentVariable("MXGW_WRITE_WAIT_DISABLED") != "1")
|
|
Assert.Skip("Set MXGW_WRITE_WAIT_DISABLED=1 (with the gateway's completion wait set to 0) to run the timeout leg.");
|
|
var (endpoint, apiKey) = RequireLiveGatewayOrSkip();
|
|
|
|
long unconfirmed = 0;
|
|
using var listener = new System.Diagnostics.Metrics.MeterListener();
|
|
listener.InstrumentPublished = (instrument, l) =>
|
|
{
|
|
if (instrument.Name == "galaxy.writes.unconfirmed") l.EnableMeasurementEvents(instrument);
|
|
};
|
|
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref unconfirmed, value));
|
|
listener.Start();
|
|
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var session = new GalaxyMxSession(new GalaxyMxAccessOptions(ClientName: "OtOpcUa06S1Leg3"));
|
|
await session.ConnectAsync(BuildClientOptions(endpoint, apiKey), ct);
|
|
var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var r = await writer.WriteAsync(
|
|
[new WriteRequest(WriteRef, 7100.0f + i)], _ => SecurityClassification.FreeAccess, ct);
|
|
Assert.Equal(0u, r[0].StatusCode); // provisional Good off the empty-statuses reply
|
|
}
|
|
|
|
listener.Dispose();
|
|
Assert.Equal(3, Interlocked.Read(ref unconfirmed));
|
|
}
|
|
|
|
private static (string Endpoint, string ApiKey) RequireLiveGatewayOrSkip()
|
|
{
|
|
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
|
|
var apiKey = Environment.GetEnvironmentVariable("GALAXY_MXGW_API_KEY");
|
|
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey))
|
|
Assert.Skip("Set MXGW_ENDPOINT + GALAXY_MXGW_API_KEY to run the live write-meter legs.");
|
|
return (endpoint!, apiKey!);
|
|
}
|
|
|
|
private static MxGatewayClientOptions BuildClientOptions(string endpoint, string apiKey) => new()
|
|
{
|
|
Endpoint = new Uri(endpoint, UriKind.Absolute),
|
|
ApiKey = apiKey,
|
|
UseTls = false,
|
|
ConnectTimeout = TimeSpan.FromSeconds(10),
|
|
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
|
StreamTimeout = TimeSpan.FromSeconds(30),
|
|
};
|
|
}
|