feat(site-runtime): WriteBatchResetAndWait — reset write + echo wait gate the batch/trigger (#36)
Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV
This commit is contained in:
@@ -964,6 +964,89 @@ public class ScriptRuntimeContext
|
||||
public async Task<bool> WriteBatchAndWait(
|
||||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||||
string responseAttr, string? responseEncoded, TimeSpan timeout)
|
||||
=> (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout)
|
||||
.ConfigureAwait(false)).Matched;
|
||||
|
||||
/// <summary>
|
||||
/// Reset-aware handshake (Gitea #36, design 2026-09-10 §5) backing the nine-argument
|
||||
/// <c>Attributes.WriteBatchAndWaitAsync</c> overload. Legs, in a FIXED order:
|
||||
/// (1) write <paramref name="resetAttr"/>=<paramref name="resetEncoded"/> through
|
||||
/// <see cref="SetAttribute"/> (throws on a device-write failure, like the batch leg);
|
||||
/// (2) wait event-driven for the device to ECHO the reset, bounded by
|
||||
/// <paramref name="resetTimeout"/> — not matched ⇒ return a <see cref="HandshakePhase.Reset"/>
|
||||
/// result and write NOTHING else; (3) the existing batch + trigger write; (4) the existing
|
||||
/// response wait bounded by <paramref name="timeout"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Why the reset must be OBSERVED before the trigger is written: a subscription reports a
|
||||
/// change from the last reported value, so a device that flips reset→done inside one
|
||||
/// sampling interval never reports the reset — the cached previous-run "done" value would
|
||||
/// then satisfy the response wait instantly (the #36 race), and an edge-triggered wait
|
||||
/// would never resolve at all. Holding the trigger until the echo lands makes the existing
|
||||
/// level-triggered fast-path correct again. Each leg is already a traced Flow Debugger
|
||||
/// step, so a traced run shows all four.
|
||||
/// </remarks>
|
||||
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
|
||||
/// <param name="triggerAttr">Scope-resolved trigger attribute written AFTER the batch.</param>
|
||||
/// <param name="triggerEncoded">Codec-encoded trigger value.</param>
|
||||
/// <param name="responseAttr">Scope-resolved attribute to wait on after the trigger.</param>
|
||||
/// <param name="responseEncoded">Codec-encoded response target (null ⇒ any change).</param>
|
||||
/// <param name="timeout">Bound for the response wait.</param>
|
||||
/// <param name="resetAttr">Scope-resolved attribute written FIRST (usually the response attribute).</param>
|
||||
/// <param name="resetEncoded">Codec-encoded reset value the device must echo before anything else is written.</param>
|
||||
/// <param name="resetTimeout">Bound for the reset-echo wait.</param>
|
||||
/// <returns>A phase-aware result; never throws on either timeout.</returns>
|
||||
public async Task<HandshakeResult> WriteBatchResetAndWait(
|
||||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||||
string responseAttr, string? responseEncoded, TimeSpan timeout,
|
||||
string resetAttr, string? resetEncoded, TimeSpan resetTimeout)
|
||||
{
|
||||
// Leg 1 — reset write. A static attribute updates the cache synchronously (leg 2 then
|
||||
// fast-paths); a data-sourced one goes to the device and the cache waits for the echo.
|
||||
await SetAttribute(resetAttr, resetEncoded ?? string.Empty).ConfigureAwait(false);
|
||||
|
||||
// Leg 2 — reset echo. Quality-agnostic, like the response wait.
|
||||
var reset = await WaitInternal(resetAttr, resetEncoded, null, resetTimeout, requireGoodQuality: false)
|
||||
.ConfigureAwait(false);
|
||||
if (!reset.Matched)
|
||||
{
|
||||
return new HandshakeResult(
|
||||
Matched: false, reset.Value, reset.Quality, reset.TimedOut, HandshakePhase.Reset,
|
||||
reset.ErrorMessage
|
||||
?? (reset.TimedOut
|
||||
? $"Timed out after {resetTimeout.TotalSeconds}s waiting for '{resetAttr}' to echo the reset value"
|
||||
: $"Reset wait for '{resetAttr}' did not match"));
|
||||
}
|
||||
|
||||
// Legs 3 + 4 — unchanged batch/trigger write and response wait.
|
||||
var response = await WriteBatchAndWaitCore(
|
||||
encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout).ConfigureAwait(false);
|
||||
return new HandshakeResult(
|
||||
response.Matched, response.Value, response.Quality, response.TimedOut, HandshakePhase.Response,
|
||||
response.Matched
|
||||
? null
|
||||
: response.ErrorMessage
|
||||
?? (response.TimedOut
|
||||
? $"Timed out after {timeout.TotalSeconds}s waiting for '{responseAttr}'"
|
||||
: $"Response wait for '{responseAttr}' did not match"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared body of <see cref="WriteBatchAndWait"/> / <see cref="WriteBatchResetAndWait"/>:
|
||||
/// the batch + trigger Ask (throws on failure) followed by the response wait, returning
|
||||
/// the full <see cref="WaitForAttributeResponse"/> so the reset-aware caller can surface
|
||||
/// value/quality/timeout.
|
||||
/// </summary>
|
||||
/// <param name="encodedValues">Scope-resolved attribute name → codec-encoded value to batch-write.</param>
|
||||
/// <param name="triggerAttr">Scope-resolved trigger attribute name written AFTER the batch.</param>
|
||||
/// <param name="triggerEncoded">Codec-encoded value for the trigger.</param>
|
||||
/// <param name="responseAttr">Scope-resolved attribute to wait on.</param>
|
||||
/// <param name="responseEncoded">Codec-encoded target value (null ⇒ any change).</param>
|
||||
/// <param name="timeout">How long to wait for the response before returning a timed-out result.</param>
|
||||
/// <returns>The full response wait result — never throws on a wait timeout.</returns>
|
||||
private async Task<WaitForAttributeResponse> WriteBatchAndWaitCore(
|
||||
IReadOnlyDictionary<string, string?> encodedValues, string triggerAttr, string? triggerEncoded,
|
||||
string responseAttr, string? responseEncoded, TimeSpan timeout)
|
||||
{
|
||||
var cid = Guid.NewGuid().ToString();
|
||||
var batchReq = new WriteAttributeBatchRequest(
|
||||
@@ -1003,7 +1086,8 @@ public class ScriptRuntimeContext
|
||||
throw new InvalidOperationException(
|
||||
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
|
||||
|
||||
return await WaitAttribute(responseAttr, responseEncoded, null, timeout).ConfigureAwait(false);
|
||||
return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED
|
||||
/// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes
|
||||
/// the batch unless the reset echo was observed. The probe stands in for the InstanceActor
|
||||
/// so the order and the "nothing after a failed reset" property are directly observable.
|
||||
/// </summary>
|
||||
public class WriteBatchResetAndWaitTests : TestKit
|
||||
{
|
||||
private const string Instance = "Plant.Reactor1";
|
||||
private static readonly IReadOnlyDictionary<string, string?> Values =
|
||||
new Dictionary<string, string?> { ["BatchID"] = "28950" };
|
||||
|
||||
private static ScriptRuntimeContext CreateContext(IActorRef instanceActor) =>
|
||||
new(instanceActor,
|
||||
new SharedScriptLibrary(
|
||||
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
|
||||
NullLogger<SharedScriptLibrary>.Instance),
|
||||
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
|
||||
instanceName: Instance, logger: NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Legs_RunInOrder_AndReportTheResponsePhase()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = CreateContext(probe.Ref);
|
||||
|
||||
var call = ctx.WriteBatchResetAndWait(
|
||||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||||
|
||||
// Leg 1: the reset write, and nothing else yet.
|
||||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("MoveOutCompleteFlag", reset.AttributeName);
|
||||
Assert.Equal("False", reset.Value);
|
||||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
|
||||
|
||||
// Leg 2: the reset-echo wait targets the RESET value and the RESET timeout.
|
||||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("MoveOutCompleteFlag", resetWait.AttributeName);
|
||||
Assert.Equal("False", resetWait.TargetValueEncoded);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10), resetWait.Timeout);
|
||||
Assert.False(resetWait.RequireGoodQuality);
|
||||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
|
||||
|
||||
// Leg 3: only now the batch + trigger.
|
||||
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("MoveOutFlag", batch.TriggerAttribute);
|
||||
Assert.Equal("True", batch.TriggerEncodedValue);
|
||||
Assert.Equal("28950", batch.AttributeEncodedValues["BatchID"]);
|
||||
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
||||
|
||||
// Leg 4: the response wait with the RESPONSE timeout.
|
||||
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("MoveOutCompleteFlag", respWait.AttributeName);
|
||||
Assert.Equal("True", respWait.TargetValueEncoded);
|
||||
Assert.Equal(TimeSpan.FromSeconds(25), respWait.Timeout);
|
||||
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, true, "Good", TimedOut: false));
|
||||
|
||||
var result = await call;
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal(HandshakePhase.Response, result.Phase);
|
||||
Assert.False(result.TimedOut);
|
||||
Assert.False(result.ResetFailed);
|
||||
Assert.Equal(true, result.Value);
|
||||
Assert.Equal("Good", result.Quality);
|
||||
Assert.Null(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = CreateContext(probe.Ref);
|
||||
|
||||
var call = ctx.WriteBatchResetAndWait(
|
||||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||||
|
||||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
|
||||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true));
|
||||
|
||||
var result = await call;
|
||||
Assert.False(result.Matched);
|
||||
Assert.True(result.TimedOut);
|
||||
Assert.Equal(HandshakePhase.Reset, result.Phase);
|
||||
Assert.True(result.ResetFailed);
|
||||
Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage);
|
||||
|
||||
// The property that fixes #36: no batch, no trigger, after a reset that was never echoed.
|
||||
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseTimeout_ReportsResponsePhase()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = CreateContext(probe.Ref);
|
||||
|
||||
var call = ctx.WriteBatchResetAndWait(
|
||||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||||
|
||||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow));
|
||||
var resetWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false));
|
||||
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
||||
var respWait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: false, null, null, TimedOut: true));
|
||||
|
||||
var result = await call;
|
||||
Assert.False(result.Matched);
|
||||
Assert.True(result.TimedOut);
|
||||
Assert.Equal(HandshakePhase.Response, result.Phase);
|
||||
Assert.False(result.ResetFailed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetWriteFailure_Throws_LikeTheBatchLeg()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = CreateContext(probe.Ref);
|
||||
|
||||
var call = ctx.WriteBatchResetAndWait(
|
||||
Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25),
|
||||
"MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10));
|
||||
|
||||
var reset = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", false, "device rejected", DateTimeOffset.UtcNow));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => call);
|
||||
Assert.Contains("device rejected", ex.Message);
|
||||
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>The bool overload is untouched: batch first, then the wait, no reset leg.</summary>
|
||||
[Fact]
|
||||
public async Task LegacyOverload_StillWritesTheBatchFirst()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = CreateContext(probe.Ref);
|
||||
|
||||
var call = ctx.WriteBatchAndWait(Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25));
|
||||
|
||||
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
||||
var wait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, true, "Good", TimedOut: false));
|
||||
|
||||
Assert.True(await call);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user