Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadPathTests.cs
T
Joseph Doherty b548cc46e6 perf(mtconnect): read through the live pump index; instance-id check on the /current fallback (R06:T-4, T-2)
- T-4: every ReadAsync batch fetched and parsed the whole device /current and
  built a fresh index even while the /sample pump held a live one. A read is now
  answered from the pump's index (read only - the pump stays its single writer)
  while the current-generation pump is running, has delivered since it started and
  is not degraded; /current stays the fallback, and its per-batch index is built
  over a tag map frozen once per binding instead of per call. A served read clears
  the read-path degradation a failed /current left.
- T-2: the /current fallback checks the answer's instanceId; a change (an Agent
  restart seen by a browse-only driver) drops the cached /probe model and raises
  rediscovery once per distinct id, only while the read's session is still installed.
- The pump also acts on no verdict (Degrade / latch) once its session generation
  is gone, so an abandoned pump cannot write health over the new session (T-1 follow-through).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:39:58 -04:00

163 lines
6.8 KiB
C#

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-4 and T-2 on the read path.
/// <list type="bullet">
/// <item>T-4 — every <c>ReadAsync</c> batch fetched + parsed the whole device <c>/current</c> even while the
/// <c>/sample</c> pump held a live, healthy index. A read now answers from that index (READ only — the pump
/// stays its single writer) while the pump is live and not degraded, and keeps <c>/current</c> as the
/// fallback.</item>
/// <item>T-2 — Agent-restart detection lived only on the pump, and the read path discarded the
/// <c>/current</c> answer's <c>instanceId</c>: a browse-only driver kept a cached <c>/probe</c> model across an
/// Agent restart and never raised rediscovery. The fallback read now checks it.</item>
/// </list>
/// </summary>
public sealed class MTConnectReadPathTests
{
private const long RestartedInstance = 1655000777L;
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static MTConnectDriverOptions Opts() => new()
{
AgentUri = "http://fixture-agent:5000",
RequestTimeoutMs = 5000,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
};
/// <summary>T-4: with a live, healthy pump the read is answered from its index — no /current round trip.</summary>
[Fact]
public async Task A_read_with_a_live_pump_answers_from_the_index_without_a_current_call()
{
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Opts(), "mt-t4", _ => client);
await driver.InitializeAsync("{}", Ct);
try
{
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "7.25")).WaitAsync(Watchdog, Ct);
var currentCalls = client.CurrentCallCount;
var result = (await driver.ReadAsync(["dev1_pos"], Ct)).Single();
result.Value.ShouldBe(7.25d, "the read must serve the pump's newest value");
client.CurrentCallCount.ShouldBe(currentCalls, "no /current while the pump's index is live");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>Positive control for T-4: with no pump the read still issues exactly one /current.</summary>
[Fact]
public async Task A_read_with_no_pump_falls_back_to_one_current_call()
{
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Opts(), "mt-t4-fallback", _ => client);
await driver.InitializeAsync("{}", Ct);
try
{
var currentCalls = client.CurrentCallCount;
await driver.ReadAsync(["dev1_pos"], Ct);
client.CurrentCallCount.ShouldBe(currentCalls + 1);
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>T-4: a pump whose stream is failing does not serve reads — they fall back to /current.</summary>
[Fact]
public async Task A_read_with_a_degraded_pump_falls_back_to_current()
{
var client = CannedAgentClient.FromFixtures();
// Every /sample fails: the pump stays alive (reconnecting under backoff) but its stream is degraded.
client.SampleFailure = new IOException("connection refused");
var options = new MTConnectDriverOptions
{
AgentUri = "http://fixture-agent:5000",
RequestTimeoutMs = 5000,
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 1000, MaxBackoffMs = 1000 },
};
var driver = new MTConnectDriver(options, "mt-t4-degraded", _ => client);
await driver.InitializeAsync("{}", Ct);
try
{
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
await client.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
var deadline = DateTime.UtcNow + Watchdog;
while (driver.GetHealth().State != DriverState.Degraded && DateTime.UtcNow < deadline)
await Task.Delay(20, Ct);
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
var currentCalls = client.CurrentCallCount;
await driver.ReadAsync(["dev1_pos"], Ct);
client.CurrentCallCount.ShouldBe(currentCalls + 1, "a degraded stream's index is not trusted for reads");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>T-2: a browse-only driver's read sees the Agent restart — once — and drops the stale model.</summary>
[Fact]
public async Task A_read_that_sees_a_new_instance_id_raises_rediscovery_once_and_drops_the_probe_model()
{
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Opts(), "mt-t2", _ => client);
await driver.InitializeAsync("{}", Ct);
var raised = 0;
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
try
{
driver.CachedProbeModel.ShouldNotBeNull("positive control: initialize cached the model");
client.Current = new MTConnectStreamsResult(RestartedInstance, 5, 1, client.Current.Observations);
await driver.ReadAsync(["dev1_pos"], Ct);
await driver.ReadAsync(["dev1_pos"], Ct);
raised.ShouldBe(1, "once per distinct instanceId, not once per read");
driver.CachedProbeModel.ShouldBeNull("the model describes a process that no longer exists");
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
/// <summary>T-2 control: reads against the same Agent process never raise rediscovery.</summary>
[Fact]
public async Task Reads_against_the_same_instance_raise_nothing()
{
var client = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Opts(), "mt-t2-same", _ => client);
await driver.InitializeAsync("{}", Ct);
var raised = 0;
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
try
{
await driver.ReadAsync(["dev1_pos"], Ct);
await driver.ReadAsync(["dev1_pos"], Ct);
raised.ShouldBe(0);
driver.CachedProbeModel.ShouldNotBeNull();
}
finally
{
await driver.ShutdownAsync(CancellationToken.None);
}
}
}