Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSampleLagTests.cs
T
Joseph Doherty 21b4f00f46 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
2026-10-22 15:50:42 -04:00

155 lines
6.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}