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:
@@ -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) =>
|
||||
|
||||
+20
-1
@@ -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 · @_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 · @_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; } = "";
|
||||
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
|
||||
+128
@@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user