b548cc46e6
- 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
163 lines
6.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|