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
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user