test(site-runtime): pin #36 — reset-aware handshake holds the batch until the reset echo lands
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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>
|
||||
/// Gitea #36 end-to-end 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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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>The defect, as documentation: the legacy overload answers from the stale cache.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user