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:
Joseph Doherty
2026-10-22 15:50:42 -04:00
parent 29c829b540
commit 21b4f00f46
12 changed files with 505 additions and 26 deletions
@@ -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()
{