Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/HandshakeResetEchoTests.cs
T
Joseph Doherty 545d3e8ba0 fix(scripts): legacy WriteBatchAndWaitAsync waits for the run's own reset echo (ScadaBridge#36)
When the run reset the response flag via SetAttribute before the six-argument
handshake, wait for that reset to echo (shared timeout) before writing batch +
trigger, so the previous run's cached CompleteFlag can't satisfy it. Covers the
unported DB-owned MES/receiver scripts. Pending reset consumed per handshake.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 04:38:05 -05:00

307 lines
16 KiB
C#

using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// End-to-end test over the REAL <see cref="InstanceActor"/> waiter and DCL ingest path:
/// the cache still holds the previous run's <c>CompleteFlag = True</c>, the script's reset
/// write has NOT echoed back yet. The legacy overload fast-paths on the stale value (the
/// defect, pinned as documentation); the reset-aware overload holds the batch + trigger until
/// the reset echo lands, then matches only on the device's fresh <c>True</c>.
/// </summary>
public class HandshakeResetEchoTests : TestKit, IDisposable
{
private const string Conn = "PLC";
private const string TriggerTag = "ns=3;s=MoveOutFlag";
private const string CompleteTag = "ns=3;s=MoveOutCompleteFlag";
private const string BatchTag = "ns=3;s=MoveOutBatchID";
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Initializes the test with a temporary LocalDb-backed site storage and script compilation services.</summary>
public HandshakeResetEchoTests()
{
_localDb = TestLocalDb.CreateTemp("handshake-reset-echo");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
// then dispose the database before deleting — the master connection anchors the WAL.
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private static FlattenedConfiguration Config() => new()
{
InstanceUniqueName = "Z28061",
Attributes =
[
new ResolvedAttribute { CanonicalName = "MoveOutFlag", Value = "false", DataType = "Boolean", DataSourceReference = TriggerTag, BoundDataConnectionName = Conn },
new ResolvedAttribute { CanonicalName = "MoveOutCompleteFlag", Value = "false", DataType = "Boolean", DataSourceReference = CompleteTag, BoundDataConnectionName = Conn },
new ResolvedAttribute { CanonicalName = "MoveOutBatchID", Value = "0", DataType = "Int32", DataSourceReference = BatchTag, BoundDataConnectionName = Conn },
]
};
private (IActorRef Actor, TestProbe Dcl, ScriptRuntimeContext Ctx) Boot()
{
var dcl = CreateTestProbe();
var actor = ActorOf(Props.Create(() => new InstanceActor(
"Z28061", JsonSerializer.Serialize(Config()), _storage, _compilationService, _sharedScriptLibrary,
null, new SiteRuntimeOptions(), NullLogger<InstanceActor>.Instance, dcl.Ref)));
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
// The previous run's completion is still in the cache: codec "True", exactly what the
// response wait targets.
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
var ctx = new ScriptRuntimeContext(
actor, _sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5), instanceName: "Z28061", logger: NullLogger.Instance);
return (actor, dcl, ctx);
}
private static IReadOnlyDictionary<string, string?> Values(int batchId)
=> new Dictionary<string, string?> { ["MoveOutBatchID"] = AttributeValueCodec.Encode(batchId) };
/// <summary>
/// Pins the legacy behaviour when the run did NOT write a reset itself: the six-argument
/// overload answers from the cache (level-triggered, the caller owns the reset). The waiter's
/// fast-path is not "fixed" in place — the implicit reset echo (Gitea #36, 2026-11-10) engages
/// only when this run's own <c>SetAttribute</c> reset the response attribute; see
/// <see cref="LegacyOverload_AfterTheScriptsOwnReset_WaitsForTheEcho_ThenMatchesTheFreshTrue"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LegacyOverload_FastPathsOnThePreviousRunsCachedTrue()
{
var (_, dcl, ctx) = Boot();
var call = ctx.WriteBatchAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5));
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
// No device update at all — and the wait still "matches".
Assert.True(await call);
}
private async Task ResetThroughScript(TestProbe dcl, ScriptRuntimeContext ctx)
{
// The MES script's own reset: `rx.Attributes["MoveOutCompleteFlag"] = false;` — a device
// write the DCL acks, with NO echo yet (the cache still holds the previous run's True).
var set = ctx.SetAttribute("MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!);
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
Assert.Equal(CompleteTag, reset.TagPath);
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
await set;
}
/// <summary>
/// Gitea #36 regression (MesMoveOut, QA 2026-09-10): the script resets CompleteFlag and then
/// calls the SIX-argument overload. Before the fix the response wait fast-pathed on the previous
/// run's cached True (0 ms) and the script read stale results. Now the batch is held until the
/// reset echoes, and only the device's fresh True completes the call.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LegacyOverload_AfterTheScriptsOwnReset_WaitsForTheEcho_ThenMatchesTheFreshTrue()
{
var (actor, dcl, ctx) = Boot();
await ResetThroughScript(dcl, ctx);
var call = ctx.WriteBatchAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10));
// Stale True in the cache, reset not echoed → no batch, no completion.
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
Assert.False(call.IsCompleted);
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
Assert.Equal(TriggerTag, batch.TriggerTagPath);
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
// The cache holds the echoed False — only THIS request's completion may finish the call.
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
Assert.False(call.IsCompleted);
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.True(await call);
}
/// <summary>
/// Gitea #36: a reset that never echoes makes the legacy handshake report a timeout and write
/// NOTHING — it must not complete from the stale cached True, nor send the trigger.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LegacyOverload_AfterTheScriptsOwnReset_NoEcho_ReturnsFalse_AndNeverWritesTheBatch()
{
var (_, dcl, ctx) = Boot();
await ResetThroughScript(dcl, ctx);
var matched = await ctx.WriteBatchAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromMilliseconds(700));
Assert.False(matched);
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Gitea #36: the pending reset is consumed by the handshake that observed it — a second legacy
/// handshake in the same run (no new reset) keeps the plain level-triggered behaviour instead
/// of waiting for an echo of a reset that already happened.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LegacyOverload_ResetIsConsumedByTheHandshakeThatObservedIt()
{
var (actor, dcl, ctx) = Boot();
await ResetThroughScript(dcl, ctx);
var first = ctx.WriteBatchAndWait(
Values(1), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10));
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
var b1 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagBatchResponse(b1.CorrelationId, true, null, DateTimeOffset.UtcNow));
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.True(await first);
var second = ctx.WriteBatchAndWait(
Values(2), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(5));
var b2 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagBatchResponse(b2.CorrelationId, true, null, DateTimeOffset.UtcNow));
Assert.True(await second);
}
/// <summary>
/// Gitea #36: the reset-aware overload's own reset write is observed by its reset leg, so it
/// must not be left pending for a later six-argument handshake in the same run (which would
/// otherwise wait for an echo of a reset that already happened and time out).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResetAwareOverload_ConsumesItsReset_SoALaterLegacyHandshakeIsNotGated()
{
var (actor, dcl, ctx) = Boot();
var first = ctx.WriteBatchResetAndWait(
Values(1), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromSeconds(10));
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
var b1 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagBatchResponse(b1.CorrelationId, true, null, DateTimeOffset.UtcNow));
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.True((await first).Matched);
var second = ctx.WriteBatchAndWait(
Values(2), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(3));
var b2 = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(2));
dcl.Reply(new WriteTagBatchResponse(b2.CorrelationId, true, null, DateTimeOffset.UtcNow));
Assert.True(await second);
}
/// <summary>Verifies that the reset-aware overload holds the batch until the reset echoes and then matches the fresh true value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResetAwareOverload_HoldsTheBatchUntilTheResetEchoes_ThenMatchesTheFreshTrue()
{
var (actor, dcl, ctx) = Boot();
var call = ctx.WriteBatchResetAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromSeconds(10));
// Leg 1 reaches the device as a plain tag write; the DCL acks it but the device has
// not echoed anything yet.
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
Assert.Equal(CompleteTag, reset.TagPath);
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
// The cache still says True → the reset wait must NOT fast-path, and no batch may go out.
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
Assert.False(call.IsCompleted);
// The device echoes the reset.
actor.Tell(new TagValueUpdate(Conn, CompleteTag, false, QualityCode.Good, DateTimeOffset.UtcNow));
// Only now the batch + trigger.
var batch = dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(5));
Assert.Equal(TriggerTag, batch.TriggerTagPath);
// TriggerValue may arrive boxed as bool or as its already-encoded string; normalise both sides.
Assert.Equal(AttributeValueCodec.Encode(true), AttributeValueCodec.Encode(batch.TriggerValue));
Assert.True(batch.Values.ContainsKey(BatchTag));
dcl.Reply(new WriteTagBatchResponse(batch.CorrelationId, true, null, DateTimeOffset.UtcNow));
// Still pending: the cache now holds False, and nothing can complete the call until the
// TagValueUpdate below is sent — no race here, only a documented intermediate state.
Assert.False(call.IsCompleted);
// The device completes THIS request.
actor.Tell(new TagValueUpdate(Conn, CompleteTag, true, QualityCode.Good, DateTimeOffset.UtcNow));
var result = await call;
Assert.True(result.Matched);
Assert.Equal(HandshakePhase.Response, result.Phase);
Assert.Equal(true, result.Value);
Assert.Equal("Good", result.Quality);
}
/// <summary>Verifies that the reset-aware overload times out in the reset phase when no echo arrives and never writes the batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResetAwareOverload_NoEcho_TimesOutInResetPhase_AndNeverWritesTheBatch()
{
var (_, dcl, ctx) = Boot();
var call = ctx.WriteBatchResetAndWait(
Values(28950), "MoveOutFlag", AttributeValueCodec.Encode(true),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(true), TimeSpan.FromSeconds(10),
"MoveOutCompleteFlag", AttributeValueCodec.Encode(false)!, TimeSpan.FromMilliseconds(700));
var reset = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
dcl.Reply(new WriteTagResponse(reset.CorrelationId, true, null, DateTimeOffset.UtcNow));
var result = await call;
Assert.False(result.Matched);
Assert.True(result.TimedOut);
Assert.True(result.ResetFailed);
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
}