545d3e8ba0
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
307 lines
16 KiB
C#
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));
|
|
}
|
|
}
|