e03f343f14
- m6: a /current read that sees a new Agent instanceId now also publishes the new process's cursor (generation-checked, captured with the read's session), so a later subscription does not open at the dead instance's sequence and flicker through a clear + re-baseline. Pinned in the T-2 test. - Documented (code + docs/drivers/MTConnect.md): m1 the bounded stale-read window of a stream stalled without failing; m4 a refused multi-pair PUT's retry may re-send an already-applied pair (harmless); m5 a non-Agent 200 now reads reachable to the ping; m8 a refused raised count takes one transient blip before the step-back. - m10 confirmed: no host key is persisted (DriverHostStatus dropped, #521) - the redacted HostName only changes an in-memory /hosts key for a query-bearing URI. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
266 lines
11 KiB
C#
266 lines
11 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");
|
|
// Review m6: the session's cursor moves to the new process, so a later subscription does not open at the
|
|
// dead instance's sequence (and flicker through a clear + re-baseline on its first chunk).
|
|
driver.AgentInstanceId.ShouldBe(RestartedInstance);
|
|
driver.AgentNextSequence.ShouldBe(5);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
private const string NoDeviceError =
|
|
"""
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
|
<Header creationTime="2026-10-22T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
|
<Errors><Error errorCode="NO_DEVICE">Could not find the device 'VMC-9'</Error></Errors>
|
|
</MTConnectError>
|
|
""";
|
|
|
|
private static Exception ParseFailure(string xml)
|
|
{
|
|
try
|
|
{
|
|
MTConnectStreamsParser.Parse(xml);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ex;
|
|
}
|
|
|
|
throw new InvalidOperationException("expected the parse to fail");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Health contract: an MTConnectError answer to /current is the Agent REFUSING (it answered) — the batch is Bad,
|
|
/// but health is restored / kept Healthy with the stamp advanced, and the refusal is logged once, not per read.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_agent_refusal_of_current_is_a_reply_not_a_comms_fault()
|
|
{
|
|
var logger = new RecordingDriverLogger();
|
|
var client = CannedAgentClient.FromFixtures();
|
|
var driver = new MTConnectDriver(Opts(), "mt-refusal", _ => client, logger);
|
|
await driver.InitializeAsync("{}", Ct);
|
|
try
|
|
{
|
|
// A comms fault first, so the refusal has a Degraded to restore.
|
|
client.CurrentFailure = new HttpRequestException("connection refused");
|
|
await driver.ReadAsync(["dev1_pos"], Ct);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault does degrade");
|
|
var stamp = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
|
|
|
var refusal = ParseFailure(NoDeviceError);
|
|
MTConnectXml.IsAgentRefusal(refusal).ShouldBeTrue();
|
|
client.CurrentFailure = refusal;
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
|
while (DateTime.UtcNow <= stamp && DateTime.UtcNow < deadline) await Task.Yield();
|
|
|
|
var first = await driver.ReadAsync(["dev1_pos"], Ct);
|
|
await driver.ReadAsync(["dev1_pos"], Ct);
|
|
|
|
first.Single().StatusCode.ShouldBe(0x80050000u, "the batch is still Bad");
|
|
var h = driver.GetHealth();
|
|
h.State.ShouldBe(DriverState.Healthy);
|
|
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(stamp);
|
|
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "a persistent refusal warns once");
|
|
}
|
|
finally
|
|
{
|
|
client.CurrentFailure = null;
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
/// <summary>An answer that is not an MTConnect document at all is NOT marked a refusal (it stays a comms fault).</summary>
|
|
[Fact]
|
|
public void A_garbage_answer_is_not_an_agent_refusal() =>
|
|
MTConnectXml.IsAgentRefusal(ParseFailure("<html><body>proxy error</body></html>")).ShouldBeFalse();
|
|
|
|
/// <summary>Review m7: a refusal that recurs AFTER a good read is news again and is logged again.</summary>
|
|
[Fact]
|
|
public async Task A_refusal_recurring_after_a_good_read_is_logged_again()
|
|
{
|
|
var logger = new RecordingDriverLogger();
|
|
var client = CannedAgentClient.FromFixtures();
|
|
var driver = new MTConnectDriver(Opts(), "mt-refusal-again", _ => client, logger);
|
|
await driver.InitializeAsync("{}", Ct);
|
|
try
|
|
{
|
|
var refusal = ParseFailure(NoDeviceError);
|
|
client.CurrentFailure = refusal;
|
|
await driver.ReadAsync(["dev1_pos"], Ct);
|
|
await driver.ReadAsync(["dev1_pos"], Ct);
|
|
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "positive control: deduplicated while it persists");
|
|
|
|
client.CurrentFailure = null;
|
|
await driver.ReadAsync(["dev1_pos"], Ct);
|
|
client.CurrentFailure = refusal;
|
|
await driver.ReadAsync(["dev1_pos"], Ct);
|
|
|
|
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(2);
|
|
}
|
|
finally
|
|
{
|
|
client.CurrentFailure = null;
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
}
|
|
}
|