From f8f6781a242bf406b59387b91df8d4f6659814a7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:23:33 -0500 Subject: [PATCH] feat(mtconnect): warn at Test Connect and deploy when SampleCount >= the Agent bufferSize (#825) Parse the /probe Header bufferSize; MTConnectDriverProbe reads sampleCount (default 1000) and answers Ok with a WARNING message (amber chip in the Test Connect button); InitializeAsync logs the same Warning and exposes agentBufferSize on the diagnostics bag. Live-checked against the shared cppagent fixture (bufferSize 16384). --- docs/drivers/MTConnect.md | 5 + .../MTConnectDriver.SampleLag.cs | 27 ++++ .../MTConnectDriver.cs | 9 ++ .../MTConnectDriverProbe.cs | 21 ++- .../MTConnectDtos.cs | 7 +- .../MTConnectProbeParser.cs | 16 ++- .../Drivers/DriverTestConnectButton.razor | 21 ++- .../MTConnectAgentIntegrationTests.cs | 29 ++++ .../MTConnectBufferSizeWarningTests.cs | 128 ++++++++++++++++++ 9 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 3731bbb23..81641b708 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -372,6 +372,11 @@ Degraded across further re-baselines until a chunk is delivered. Values keep flo off the re-baselines and `LastSuccessfulRead` keeps advancing — each `/current` is the Agent answering — so the comms-loss watchdog does not reconnect-loop a configuration problem. Size `SampleCount` below the Agent's `bufferSize` (in the `/probe` header). +**It is caught before it happens (#825):** the `/probe` header's `bufferSize` is parsed +(`MTConnectProbeModel.BufferSize`, advisory — missing/malformed is null, never a parse failure); **Test +Connect** reads `sampleCount` (default 1000 when absent) and answers Ok **with a warning** (an amber +"OK with warning" chip showing the text) when it is at or above the buffer, and the driver's **initialize** +(every deploy) logs the same Warning and puts `agentBufferSize` on the `/hosts` diagnostics bag. **An Agent `instanceId` change means the Agent restarted**, and is checked **before** the sequence-gap check (a restart also usually trips the gap, so diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs index 210bf153e..3d6594792 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.SampleLag.cs @@ -55,6 +55,27 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics /// Times a pump raised its count. Diagnostics only. private long _sampleCountRaises; + /// The Agent's /probe bufferSize as of the last initialize, or 0 when unknown. Diagnostics only. + private long _agentBufferSize; + + /// + /// lmxopcua#825 — the configuration warning for a the Agent + /// will refuse. An Agent answers /sample?count=N with OUT_OF_RANGE ("'count' must be less than + /// bufferSize") when N ≥ bufferSize, so such a driver never streams: it re-baselines from + /// /current and reports Degraded after three of them. Shared by + /// (Test Connect) and the driver's initialize (deploy), so both say the same thing. + /// + /// The configured count. + /// The Agent's buffer size, or null when its header did not say. + /// The warning text, or when the count fits (or the buffer size is unknown). + internal static string? SampleCountBufferWarning(int sampleCount, long? bufferSize) => + bufferSize is { } size && sampleCount >= size + ? $"SampleCount {sampleCount.ToString(CultureInfo.InvariantCulture)} is at or above the Agent's bufferSize " + + $"{size.ToString(CultureInfo.InvariantCulture)}: the Agent refuses every /sample stream (OUT_OF_RANGE), so " + + "values will only re-baseline from /current and the driver reports Degraded. Set SampleCount below " + + $"{size.ToString(CultureInfo.InvariantCulture)}." + : null; + /// The ceiling a pump started with may raise its count to. /// The configured . /// The ceiling — never below . @@ -77,6 +98,12 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics bag["sampleLag"] = lag.ToString(CultureInfo.InvariantCulture); } + var bufferSize = Interlocked.Read(ref _agentBufferSize); + if (bufferSize > 0) + { + bag["agentBufferSize"] = bufferSize.ToString(CultureInfo.InvariantCulture); + } + bag["sampleSaturatedChunks"] = Interlocked.Read(ref _saturatedChunks).ToString(CultureInfo.InvariantCulture); bag["sampleCountRaises"] = Interlocked.Read(ref _sampleCountRaises).ToString(CultureInfo.InvariantCulture); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index dd6b53940..352220eb5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -3333,6 +3333,15 @@ public sealed partial class MTConnectDriver // ---- commit point: everything below is non-throwing bookkeeping ---- Volatile.Write(ref _options, options); + // lmxopcua#825 — a SampleCount the Agent will refuse is a configuration error; say so at deploy rather than + // leaving it to surface as three progress-free re-baselines and a Degraded stream. + Interlocked.Exchange(ref _agentBufferSize, probe.BufferSize ?? 0); + if (SampleCountBufferWarning(options.SampleCount, probe.BufferSize) is { } bufferWarning) + { + _logger.LogWarning("MTConnect driver {DriverInstanceId} ({AgentUri}): {Warning}", + _driverInstanceId, AgentDisplay(options), bufferWarning); + } + // Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig // blob declares no type takes the type the Agent itself declares for that DataItem, so a // browse-committed tag (whose blob carries only the address) publishes as the number it diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs index 0e668b404..cc402eb07 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs @@ -132,8 +132,18 @@ public sealed class MTConnectDriverProbe : IDriverProbe /// driver's tag/timing configuration — see the "reachable ≠ deployable" remarks on the type. /// public string? DeviceName { get; set; } + + /// + /// The /sample count (lmxopcua#825). Not needed to reach the Agent, but read so Test Connect + /// can warn when it is at or above the Agent's bufferSize — a config the Agent will refuse on every + /// stream. Absent ⇒ the driver's own default. + /// + public int? SampleCount { get; set; } } + /// The driver's default SampleCount (MTConnectDriver.ParseOptions) when the config omits it. + private const int DefaultSampleCount = 1_000; + /// public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { @@ -195,10 +205,17 @@ public sealed class MTConnectDriverProbe : IDriverProbe try { using var client = new MTConnectAgentClient(options, _handler); - _ = await client.ProbeAsync(cts.Token).ConfigureAwait(false); + var model = await client.ProbeAsync(cts.Token).ConfigureAwait(false); sw.Stop(); - return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed); + // Reachable but misconfigured stays Ok (the endpoint answered — Test Connect's question), with the + // warning in the message so the operator sees it before deploying (lmxopcua#825). + var warning = MTConnectDriver.SampleCountBufferWarning(dto.SampleCount ?? DefaultSampleCount, model.BufferSize); + return new(true, + warning is null + ? $"MTConnect /probe OK ({safeUri})" + : $"MTConnect /probe OK ({safeUri}) — WARNING: {warning}", + sw.Elapsed); } catch (OperationCanceledException) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs index d9992bf01..a7b6ef792 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs @@ -8,7 +8,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// XElement. /// /// Every device the Agent's probe response declares. -public sealed record MTConnectProbeModel(IReadOnlyList Devices); +/// +/// The Agent's observation ring-buffer size (the /probe Header's bufferSize), or +/// when the header omits it or it is not a positive integer. A /sample +/// count at or above it is refused OUT_OF_RANGE by the Agent (lmxopcua#825). +/// +public sealed record MTConnectProbeModel(IReadOnlyList Devices, long? BufferSize = null); /// /// Common shape shared by and : diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs index d50d66bfa..e9561a22b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs @@ -132,7 +132,21 @@ internal static class MTConnectProbeParser "MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe."); } - return new MTConnectProbeModel(devices); + return new MTConnectProbeModel(devices, ReadBufferSize(root)); + } + + /// + /// The Header's bufferSize (lmxopcua#825). Advisory only — a missing, malformed or non-positive + /// value is , never a parse failure, because nothing but a configuration warning reads it. + /// + private static long? ReadBufferSize(XElement root) + { + var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault(); + var raw = header is null ? null : MTConnectXml.OptionalAttribute(header, "bufferSize"); + return long.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var size) + && size > 0 + ? size + : null; } private static MTConnectDevice ReadDevice(XElement element) => diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor index 06ec04ec1..f133dae8f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverTestConnectButton.razor @@ -12,7 +12,16 @@ @if (_result is not null) { - @if (_result.Ok) + @if (_result.Ok && WarningOf(_result.Message) is { } warning) + { + @* Reachable but misconfigured (e.g. MTConnect SampleCount >= the Agent's bufferSize, #825): a probe + that answers Ok with a "WARNING:" message is shown amber with the warning, never a plain green OK. *@ + + OK with warning · @_result.LatencyMs?.ToString("F0") ms + +
@warning
+ } + else if (_result.Ok) { OK · @_result.LatencyMs?.ToString("F0") ms @@ -28,6 +37,16 @@ @code { + /// The marker a probe puts in an Ok message to flag a warning (see MTConnectDriverProbe). + internal const string WarningMarker = "WARNING:"; + + /// The warning text after , or null when the message carries none. + internal static string? WarningOf(string? message) + { + var at = message?.IndexOf(WarningMarker, StringComparison.Ordinal) ?? -1; + return at < 0 ? null : message![(at + WarningMarker.Length)..].Trim(); + } + /// Driver type key — must match an installed IDriverProbe.DriverType. [Parameter, EditorRequired] public string DriverType { get; set; } = ""; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs index 47a57aa6d..85639ebcd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs @@ -947,6 +947,35 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) seen.Skip(before).ShouldContain(e => e.Snapshot.StatusCode == Good); } + /// + /// #825 — Test Connect reads the live Agent's /probe bufferSize and warns (still Ok) when the + /// configured SampleCount would be refused; one below the buffer is the falsifiability control. + /// + [Fact(Timeout = TestTimeoutMs)] + public async Task Test_Connect_warns_when_SampleCount_reaches_the_live_agents_buffer_size() + { + SkipIfDown(); + + var bufferSize = (int?)agent.ProbeDocument?.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "Header") + ?.Attribute("bufferSize"); + if (bufferSize is not > 1) + { + Assert.Skip($"The Agent at {agent.AgentUri} declares no usable bufferSize in its /probe header."); + } + + var refused = await new MTConnectDriverProbe().ProbeAsync( + JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value }), + TimeSpan.FromSeconds(15), Ct); + refused.Ok.ShouldBeTrue(refused.Message); + refused.Message!.ShouldContain($"WARNING: SampleCount {bufferSize.Value} is at or above the Agent's bufferSize {bufferSize.Value}"); + + var fits = await new MTConnectDriverProbe().ProbeAsync( + JsonSerializer.Serialize(new { agentUri = agent.AgentUri, sampleCount = bufferSize.Value - 1 }), + TimeSpan.FromSeconds(15), Ct); + fits.Ok.ShouldBeTrue(fits.Message); + fits.Message!.ShouldNotContain("WARNING"); + } + private static bool Is(string? value, string expected) => string.Equals(value?.Trim(), expected, StringComparison.OrdinalIgnoreCase); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs new file mode 100644 index 000000000..1f6c72e67 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBufferSizeWarningTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Net.Http; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// lmxopcua#825 — a SampleCount at or above the Agent's bufferSize is refused OUT_OF_RANGE on +/// every /sample stream. The /probe header carries bufferSize, so Test Connect and the +/// driver's initialize (deploy) both warn instead of leaving it to surface as a Degraded stream. +/// +[Trait("Category", "Unit")] +public sealed class MTConnectBufferSizeWarningTests +{ + private static string ProbeXml(string? bufferSizeAttribute) => + $""" + + +
+ + + + + + + """; + + [Theory] + [InlineData("16384", 16384L)] + [InlineData(null, null)] + [InlineData("0", null)] + [InlineData("-5", null)] + [InlineData("lots", null)] + public void The_probe_parser_reads_the_header_buffer_size_and_never_fails_on_it(string? attribute, long? expected) => + MTConnectProbeParser.Parse(ProbeXml(attribute)).BufferSize.ShouldBe(expected); + + [Theory] + [InlineData(16383, 16384L, false)] + [InlineData(16384, 16384L, true)] + [InlineData(20000, 16384L, true)] + [InlineData(1_000_000, null, false)] + public void The_warning_fires_exactly_at_and_above_the_buffer_size(int sampleCount, long? bufferSize, bool warns) + { + var warning = MTConnectDriver.SampleCountBufferWarning(sampleCount, bufferSize); + (warning is not null).ShouldBe(warns); + if (warns) warning!.ShouldContain($"below {bufferSize}"); + } + + [Fact] + public async Task Test_Connect_stays_ok_but_carries_the_warning_when_SampleCount_does_not_fit() + { + var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("16384"))); + var r = await probe.ProbeAsync( + "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":16384}", TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + r.Ok.ShouldBeTrue("the Agent answered — reachability is not the problem"); + r.Message!.ShouldContain("WARNING:"); + r.Message!.ShouldContain("bufferSize 16384"); + } + + [Fact] + public async Task Test_Connect_checks_the_driver_default_when_SampleCount_is_absent() + { + // The default SampleCount is 1000 — an Agent with a tiny buffer refuses it too. + var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("512"))); + var r = await probe.ProbeAsync( + "{\"agentUri\":\"http://stub-agent:5000\"}", TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + + r.Ok.ShouldBeTrue(); + r.Message!.ShouldContain("SampleCount 1000 is at or above the Agent's bufferSize 512"); + } + + [Fact] + public async Task Test_Connect_message_is_unchanged_when_SampleCount_fits() + { + var probe = new MTConnectDriverProbe(new StubHandler(ProbeXml("16384"))); + var r = await probe.ProbeAsync( + "{\"agentUri\":\"http://stub-agent:5000\",\"sampleCount\":1000}", TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + r.Ok.ShouldBeTrue(); + r.Message!.ShouldNotContain("WARNING"); + } + + [Theory] + [InlineData(131072, true)] + [InlineData(1000, false)] + public async Task Initialize_warns_and_surfaces_the_buffer_size_on_the_diagnostics_bag(int sampleCount, bool warns) + { + var client = CannedAgentClient.FromFixtures(); // Fixtures/probe.xml: bufferSize="131072" + client.Probe.BufferSize.ShouldBe(131072L); + var logger = new RecordingDriverLogger(); + var driver = new MTConnectDriver( + new MTConnectDriverOptions + { + AgentUri = "http://fixture-agent:5000", + RequestTimeoutMs = 5000, + SampleCount = sampleCount, + Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)], + }, + "mt-825", + _ => client, + logger); + try + { + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + logger.Warnings.Any(w => w.Contains("bufferSize", StringComparison.Ordinal)).ShouldBe(warns); + driver.GetDiagnostics()["agentBufferSize"].ShouldBe("131072"); + } + finally + { + await driver.ShutdownAsync(TestContext.Current.CancellationToken); + } + } + + private sealed class StubHandler(string probeXml) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(probeXml, System.Text.Encoding.UTF8, "application/xml"), + }); + } +}