feat(mtconnect): /sample lag measurement + adaptive count (R06:T-5)
Subscription throughput was hard-capped at SampleCount / SampleIntervalMs with no lag detection: a machine producing more fell behind the ring buffer forever, re-baselining every buffer-length with every value backlog-stale. - The parser carries the header's optional lastSequence; each chunk measures lag = lastSequence + 1 - nextSequence and whether it was saturated (full count). - Three consecutive saturated chunks with a positive (or unknown) lag reopen the stream at the cursor reached with count doubled, up to 16x SampleCount (max 65536), through a new IMTConnectAgentClient.SampleAsync(from, count, ct) overload (default implementation ignores count). A raised stream that delivers nothing steps back to the last good count; at the ceiling one Warning per pump. - MTConnectDriver implements IDriverDiagnostics: sampleCount, sampleLag, sampleSaturatedChunks, sampleCountRaises on the /hosts bag. The cap is documented on SampleCount. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It honours the seam's stream-end contract</b> (see
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/>): cancelling the token is the only way the
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>): cancelling the token is the only way the
|
||||
/// enumeration ends without throwing. Closing the scripted stream via
|
||||
/// <see cref="EndStream"/> raises <see cref="MTConnectStreamEndedException"/>, exactly as the
|
||||
/// production client does when an Agent drops the connection — so a pump written against the
|
||||
@@ -297,6 +297,20 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return CurrentFailure is null ? Current : throw CurrentFailure;
|
||||
}
|
||||
|
||||
/// <summary>The <c>count</c> the most recent <c>/sample</c> enumeration was opened with (null = the 2-arg overload).</summary>
|
||||
public int? LastSampleCount { get; private set; }
|
||||
|
||||
/// <summary>Every <c>count</c> a <c>/sample</c> enumeration was opened with, in order.</summary>
|
||||
public ConcurrentQueue<int> SampleCounts { get; } = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct)
|
||||
{
|
||||
LastSampleCount = count;
|
||||
SampleCounts.Enqueue(count);
|
||||
return SampleAsync(from, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from, [EnumeratorCancellation] CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-5 — <c>/sample</c> throughput was hard-capped at <c>SampleCount / SampleIntervalMs</c>
|
||||
/// with no lag detection: a machine producing more fell behind the Agent's ring buffer forever, re-baselining
|
||||
/// every buffer-length with every value backlog-stale and nothing surfacing it. The pump now measures the lag
|
||||
/// (<c>lastSequence + 1 − nextSequence</c>, plus chunk saturation), surfaces it on the diagnostics bag, and raises
|
||||
/// the per-stream <c>count</c> when it is persistently behind.
|
||||
/// </summary>
|
||||
public sealed class MTConnectSampleLagTests
|
||||
{
|
||||
private const long Instance = 1655000000L;
|
||||
private const long Primed = 108L;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>A chunk of <paramref name="observations"/> dev1_pos values from <paramref name="first"/>.</summary>
|
||||
private static MTConnectStreamsResult Chunk(long first, int observations, long? last)
|
||||
{
|
||||
var obs = Enumerable.Range(0, observations)
|
||||
.Select(i => new MTConnectObservation(
|
||||
"dev1_pos", (first + i).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc).AddSeconds(first + i)))
|
||||
.ToList();
|
||||
return new MTConnectStreamsResult(Instance, first + observations, first, obs, last);
|
||||
}
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> SubscribedDriverAsync(int sampleCount)
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
SampleCount = sampleCount,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t5",
|
||||
_ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
return (driver, client);
|
||||
}
|
||||
|
||||
/// <summary>Three full chunks with a positive lag reopen the stream, at the cursor reached, with count doubled.</summary>
|
||||
[Fact]
|
||||
public async Task A_persistently_behind_stream_reopens_with_a_doubled_count()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
await client.PumpAsync(Chunk(Primed, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
await client.PumpAsync(Chunk(Primed + 2, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
await client.PumpAsync(Chunk(Primed + 4, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.ShouldBe([2, 4]);
|
||||
client.LastSampleFrom.ShouldBe(Primed + 6, "the raised stream resumes where the behind one got to");
|
||||
|
||||
var diag = driver.GetDiagnostics();
|
||||
diag["sampleCount"].ShouldBe("4");
|
||||
diag["sampleLag"].ShouldBe((900 + 1 - (Primed + 6)).ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
diag["sampleSaturatedChunks"].ShouldBe("3");
|
||||
diag["sampleCountRaises"].ShouldBe("1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An Agent that omits lastSequence: persistent saturation alone is enough.</summary>
|
||||
[Fact]
|
||||
public async Task Persistent_saturation_without_a_lastSequence_still_raises_the_count()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
await client.PumpAsync(Chunk(Primed + (2 * i), 2, last: null)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.ShouldBe([2, 4]);
|
||||
driver.GetDiagnostics().ContainsKey("sampleLag").ShouldBeFalse("an unknown lag is not reported as a number");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full chunks with NO lag (the Agent happened to have exactly count observations) are not "behind": the stream
|
||||
/// is kept. Positive control: the first test, through the same pump, does reopen.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Full_chunks_with_no_lag_keep_the_stream()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var first = Primed + (2 * i);
|
||||
await client.PumpAsync(Chunk(first, 2, last: first + 1)).WaitAsync(Watchdog, Ct);
|
||||
}
|
||||
|
||||
client.SampleCallCount.ShouldBe(1, "a caught-up stream must not be reopened");
|
||||
driver.GetDiagnostics()["sampleLag"].ShouldBe("0");
|
||||
driver.GetDiagnostics()["sampleCountRaises"].ShouldBe("0");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A raised count the Agent refuses (its stream delivers nothing) steps back to the last count that worked.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_raised_count_steps_back_to_the_last_count_that_delivered()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
// The NEXT enumeration (the raised one) is refused the way a real Agent refuses a count above its buffer.
|
||||
client.SampleFailures.Enqueue(new InvalidDataException("MTConnectError: INVALID_REQUEST count too large"));
|
||||
for (var i = 0; i < 3; i++)
|
||||
await client.PumpAsync(Chunk(Primed + (2 * i), 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.Take(3).ShouldBe([2, 4, 2]);
|
||||
driver.GetDiagnostics()["sampleCount"].ShouldBe("2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ceiling is 16× the configured count, capped at 65 536, and never below the configured count.</summary>
|
||||
[Theory]
|
||||
[InlineData(1000, 16000)]
|
||||
[InlineData(10_000, 65_536)]
|
||||
[InlineData(100_000, 100_000)]
|
||||
public void The_count_ceiling_is_bounded(int configured, int expected) =>
|
||||
MTConnectDriver.SampleCountCeiling(configured).ShouldBe(expected);
|
||||
}
|
||||
@@ -593,6 +593,38 @@ public sealed class MTConnectStreamsParseTests
|
||||
"http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-5 — the count overload sends the raised count in place of the configured one.</summary>
|
||||
[Fact]
|
||||
public async Task SampleAsync_with_a_count_sends_that_count_instead_of_the_configured_one()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://agent:5000",
|
||||
DeviceName = "VMC-3Axis",
|
||||
SampleIntervalMs = 250,
|
||||
SampleCount = 64,
|
||||
HeartbeatMs = 7000
|
||||
});
|
||||
|
||||
await DrainAsync(client.SampleAsync(108, 256, Ct));
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe(
|
||||
"http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=256&heartbeat=7000");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-5 — the header's lastSequence is carried (it measures the stream's lag); absent ⇒ null.</summary>
|
||||
[Fact]
|
||||
public async Task The_header_lastSequence_is_carried_when_present_and_null_when_absent()
|
||||
{
|
||||
var current = MTConnectStreamsParser.Parse(await File.ReadAllTextAsync("Fixtures/current.xml", Ct));
|
||||
current.LastSequence.ShouldBe(107);
|
||||
|
||||
var stripped = (await File.ReadAllTextAsync("Fixtures/current.xml", Ct)).Replace("lastSequence=\"107\"", string.Empty);
|
||||
stripped.ShouldNotContain("lastSequence");
|
||||
MTConnectStreamsParser.Parse(stripped).LastSequence.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_yields_one_result_per_multipart_chunk()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user