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).
This commit is contained in:
Joseph Doherty
2026-11-11 01:23:33 -05:00
parent 40969e5c4b
commit f8f6781a24
9 changed files with 258 additions and 5 deletions
+5
View File
@@ -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
@@ -55,6 +55,27 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
/// <summary>Times a pump raised its count. Diagnostics only.</summary>
private long _sampleCountRaises;
/// <summary>The Agent's <c>/probe</c> <c>bufferSize</c> as of the last initialize, or 0 when unknown. Diagnostics only.</summary>
private long _agentBufferSize;
/// <summary>
/// lmxopcua#825 — the configuration warning for a <see cref="MTConnectDriverOptions.SampleCount"/> the Agent
/// will refuse. An Agent answers <c>/sample?count=N</c> with <c>OUT_OF_RANGE</c> ("'count' must be less than
/// bufferSize") when <c>N ≥ bufferSize</c>, so such a driver never streams: it re-baselines from
/// <c>/current</c> and reports Degraded after three of them. Shared by <see cref="MTConnectDriverProbe"/>
/// (Test Connect) and the driver's initialize (deploy), so both say the same thing.
/// </summary>
/// <param name="sampleCount">The configured count.</param>
/// <param name="bufferSize">The Agent's buffer size, or null when its header did not say.</param>
/// <returns>The warning text, or <see langword="null"/> when the count fits (or the buffer size is unknown).</returns>
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;
/// <summary>The ceiling a pump started with <paramref name="configured"/> may raise its count to.</summary>
/// <param name="configured">The configured <see cref="MTConnectDriverOptions.SampleCount"/>.</param>
/// <returns>The ceiling — never below <paramref name="configured"/>.</returns>
@@ -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);
@@ -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
@@ -132,8 +132,18 @@ public sealed class MTConnectDriverProbe : IDriverProbe
/// driver's tag/timing configuration — see the "reachable ≠ deployable" remarks on the type.
/// </summary>
public string? DeviceName { get; set; }
/// <summary>
/// The <c>/sample</c> <c>count</c> (lmxopcua#825). Not needed to reach the Agent, but read so Test Connect
/// can warn when it is at or above the Agent's <c>bufferSize</c> — a config the Agent will refuse on every
/// stream. Absent ⇒ the driver's own default.
/// </summary>
public int? SampleCount { get; set; }
}
/// <summary>The driver's default <c>SampleCount</c> (<c>MTConnectDriver.ParseOptions</c>) when the config omits it.</summary>
private const int DefaultSampleCount = 1_000;
/// <inheritdoc />
public async Task<DriverProbeResult> 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)
{
@@ -8,7 +8,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <c>XElement</c>.
/// </summary>
/// <param name="Devices">Every device the Agent's probe response declares.</param>
public sealed record MTConnectProbeModel(IReadOnlyList<MTConnectDevice> Devices);
/// <param name="BufferSize">
/// The Agent's observation ring-buffer size (the <c>/probe</c> <c>Header</c>'s <c>bufferSize</c>), or
/// <see langword="null"/> when the header omits it or it is not a positive integer. A <c>/sample</c>
/// <c>count</c> at or above it is refused <c>OUT_OF_RANGE</c> by the Agent (lmxopcua#825).
/// </param>
public sealed record MTConnectProbeModel(IReadOnlyList<MTConnectDevice> Devices, long? BufferSize = null);
/// <summary>
/// Common shape shared by <see cref="MTConnectDevice"/> and <see cref="MTConnectComponent"/>:
@@ -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));
}
/// <summary>
/// The <c>Header</c>'s <c>bufferSize</c> (lmxopcua#825). Advisory only — a missing, malformed or non-positive
/// value is <see langword="null"/>, never a parse failure, because nothing but a configuration warning reads it.
/// </summary>
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) =>
@@ -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. *@
<span class="chip chip-warn" title="@warning">
OK with warning &middot; @_result.LatencyMs?.ToString("F0") ms
</span>
<div class="small text-warning mt-1">@warning</div>
}
else if (_result.Ok)
{
<span class="chip chip-ok" title="@($"Probe succeeded in {_result.LatencyMs:F0} ms")">
OK &middot; @_result.LatencyMs?.ToString("F0") ms
@@ -28,6 +37,16 @@
</div>
@code {
/// <summary>The marker a probe puts in an <c>Ok</c> message to flag a warning (see <c>MTConnectDriverProbe</c>).</summary>
internal const string WarningMarker = "WARNING:";
/// <summary>The warning text after <see cref="WarningMarker"/>, or null when the message carries none.</summary>
internal static string? WarningOf(string? message)
{
var at = message?.IndexOf(WarningMarker, StringComparison.Ordinal) ?? -1;
return at < 0 ? null : message![(at + WarningMarker.Length)..].Trim();
}
/// <summary>Driver type key — must match an installed <c>IDriverProbe.DriverType</c>.</summary>
[Parameter, EditorRequired] public string DriverType { get; set; } = "";
@@ -947,6 +947,35 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
seen.Skip(before).ShouldContain(e => e.Snapshot.StatusCode == Good);
}
/// <summary>
/// #825 — Test Connect reads the live Agent's <c>/probe</c> <c>bufferSize</c> and warns (still Ok) when the
/// configured <c>SampleCount</c> would be refused; one below the buffer is the falsifiability control.
/// </summary>
[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);
@@ -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;
/// <summary>
/// lmxopcua#825 — a <c>SampleCount</c> at or above the Agent's <c>bufferSize</c> is refused <c>OUT_OF_RANGE</c> on
/// every <c>/sample</c> stream. The <c>/probe</c> header carries <c>bufferSize</c>, so Test Connect and the
/// driver's initialize (deploy) both warn instead of leaving it to surface as a Degraded stream.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectBufferSizeWarningTests
{
private static string ProbeXml(string? bufferSizeAttribute) =>
$"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0">
<Header instanceId="1"{(bufferSizeAttribute is null ? "" : $" bufferSize=\"{bufferSizeAttribute}\"")}/>
<Devices>
<Device id="d" name="D">
<DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
</Device>
</Devices>
</MTConnectDevices>
""";
[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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(probeXml, System.Text.Encoding.UTF8, "application/xml"),
});
}
}