feat(siteruntime): event-driven Attributes.WaitAsync attribute-change helper
Adds InstanceActor one-shot waiter registry (fast-path + change-match + scheduled timeout self-eviction), threads per-script timeout token through ScriptRuntimeContext, and exposes Attributes.WaitAsync(value|predicate, timeout). Replaces handshake busy-poll. Implements spec docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md §3-§5; §6 routed variant + WaitForAsync + quality-only mode deferred.
This commit is contained in:
+341
@@ -0,0 +1,341 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
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 System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the event-driven <c>WaitForAttribute</c> one-shot waiter registry in
|
||||
/// <see cref="InstanceActor"/> (Attributes.WaitAsync spec §3-§5). Covers the
|
||||
/// fast-path, change-match, timeout, no-leak (timeout-canceled-on-match), and
|
||||
/// predicate-overload acceptance criteria.
|
||||
/// </summary>
|
||||
public class InstanceActorWaitForAttributeTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly string _dbFile;
|
||||
|
||||
public InstanceActorWaitForAttributeTests()
|
||||
{
|
||||
_dbFile = Path.Combine(Path.GetTempPath(), $"instance-waitfor-test-{Guid.NewGuid():N}.db");
|
||||
_storage = new SiteStorageService(
|
||||
$"Data Source={_dbFile}",
|
||||
NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
_options = new SiteRuntimeOptions();
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config)
|
||||
{
|
||||
return ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null, // no stream manager in tests
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance)));
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||
}
|
||||
|
||||
// ── 1. Fast-path: attribute already at target ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.1: when the attribute already equals the target at the time
|
||||
/// the waiter registers, the actor must reply immediately with Matched=true
|
||||
/// (carrying the current value), without scheduling a timeout.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_FastPath_AlreadyAtTarget_RepliesMatchedImmediately()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Flag", Value = "true", DataType = "Boolean" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-fast", "Pump1", "Flag",
|
||||
"true", null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-fast", response.CorrelationId);
|
||||
Assert.Equal("true", response.Value?.ToString());
|
||||
}
|
||||
|
||||
// ── 2. Change-match: register first, then drive a value change ───────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.1/§7.4: registering when the value does NOT match, then
|
||||
/// driving the attribute to the target value (via a DCL TagValueUpdate) must
|
||||
/// produce a single Matched=true reply carrying the new value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_ChangeMatch_RepliesMatchedWithNewValue()
|
||||
{
|
||||
const string tag = "ns=3;s=Recipe.Processed";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Processed", Value = "false", DataType = "Boolean",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Register: current value "false" does not match the target. The value
|
||||
// arrives from the DCL as a boolean true, whose codec-encoded form is
|
||||
// "True" — so the target must be encoded the same way the accessor would
|
||||
// (AttributeValueCodec.Encode(true)), NOT the literal string "true".
|
||||
var target = ZB.MOM.WW.ScadaBridge.Commons.Types.AttributeValueCodec.Encode(true);
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-change", "Pump1", "Processed",
|
||||
target, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
// No reply yet — the value has not changed to the target.
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Drive the value to the target through the DCL ingest path.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, true, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal("wfa-change", response.CorrelationId);
|
||||
Assert.Equal(true, response.Value);
|
||||
Assert.Equal("Good", response.Quality);
|
||||
}
|
||||
|
||||
// ── 3. Timeout: value never matches ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.2: when the attribute never reaches the target within the
|
||||
/// timeout, the actor replies Matched=false, TimedOut=true (no throw).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_Timeout_RepliesNotMatchedTimedOut()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Flag", Value = "false", DataType = "Boolean" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-timeout", "Pump1", "Flag",
|
||||
"true", null, TimeSpan.FromMilliseconds(300), DateTimeOffset.UtcNow));
|
||||
|
||||
// The scheduled timeout fires; allow a tolerant deadline.
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(3));
|
||||
Assert.False(response.Matched);
|
||||
Assert.True(response.TimedOut);
|
||||
Assert.Equal("wfa-timeout", response.CorrelationId);
|
||||
}
|
||||
|
||||
// ── 4. No-leak: timeout canceled on match (no second reply) ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7.5: after a successful change-match, the scheduled timeout
|
||||
/// must have been canceled and the waiter removed — so NO second (timeout)
|
||||
/// response arrives after the match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_Match_CancelsTimeout_NoSecondReply()
|
||||
{
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute { CanonicalName = "Flag", Value = "false", DataType = "Boolean" }
|
||||
]
|
||||
};
|
||||
|
||||
var actor = CreateInstanceActor("Pump1", config);
|
||||
|
||||
// Register with a short timeout, then match BEFORE it would fire.
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-noleak", "Pump1", "Flag",
|
||||
"true", null, TimeSpan.FromMilliseconds(500), DateTimeOffset.UtcNow));
|
||||
|
||||
// Drive the static value to the target; the actor publishes via
|
||||
// HandleAttributeValueChanged, satisfying the waiter.
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"set-flag", "Pump1", "Flag", "true", DateTimeOffset.UtcNow));
|
||||
|
||||
// First reply: the match. (A SetStaticAttributeResponse also arrives for
|
||||
// the set command — filter for the WaitForAttributeResponse.)
|
||||
var matched = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(matched.Matched);
|
||||
Assert.False(matched.TimedOut);
|
||||
|
||||
// The set command's own ack — drain it so the no-msg assert below is clean.
|
||||
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// No second WaitForAttributeResponse (the timeout was canceled) for longer
|
||||
// than the original 500ms timeout window.
|
||||
ExpectNoMsg(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
// ── 5. Predicate overload ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Acceptance §7 (predicate form): registering with a site-local predicate and
|
||||
/// then flipping the value so the predicate passes must produce Matched=true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_PredicateOverload_MatchesOnPredicatePass()
|
||||
{
|
||||
const string tag = "ns=3;s=Level";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Level", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// Predicate: value > 50 (current is 0, so no immediate match).
|
||||
Func<object?, bool> predicate = v =>
|
||||
v is not null && int.TryParse(v.ToString(), out var n) && n > 50;
|
||||
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-pred", "Pump1", "Level",
|
||||
null, predicate, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// A value below the threshold must NOT satisfy the predicate.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 25, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// A value above the threshold satisfies it.
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 75, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
Assert.Equal(75, response.Value);
|
||||
}
|
||||
|
||||
// ── 6. "any change" (null target + null predicate) ───────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Spec §4.1: a null TargetValueEncoded + null Predicate means "wait for any
|
||||
/// change" — the next value update on that attribute matches.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForAttribute_AnyChange_MatchesOnNextUpdate()
|
||||
{
|
||||
const string tag = "ns=3;s=Speed";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump1",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Speed", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||
"Pump1",
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
dcl.Ref)));
|
||||
|
||||
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// "any change" registers with a non-trivial timeout. The fast-path uses
|
||||
// `_ => true`, so a currently-present attribute matches immediately.
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-any", "Pump1", "Speed",
|
||||
null, null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
|
||||
// Speed=0 is already present, so the "any change" test (_ => true) matches
|
||||
// immediately on the fast path.
|
||||
var response = ExpectMsg<WaitForAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(response.Matched);
|
||||
Assert.False(response.TimedOut);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
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.Commons.Types.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
@@ -137,3 +142,81 @@ public class ScopeAccessorTests
|
||||
Assert.Equal("[1,2,3]", encoded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WaitAsync (spec §3-§5, acceptance §7.6) scope-resolution tests. Unlike the
|
||||
/// path-arithmetic tests above, these route a real <see cref="ScriptRuntimeContext"/>
|
||||
/// against a TestProbe standing in for the Instance Actor, so they need a live
|
||||
/// ActorSystem — hence a TestKit-derived class. They assert that
|
||||
/// <c>Attributes.WaitAsync</c> applies <see cref="AttributeAccessor.Resolve"/>
|
||||
/// (the composition prefix) to the key BEFORE the request is sent to the actor —
|
||||
/// the same contract Get/Set obey.
|
||||
/// </summary>
|
||||
public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
{
|
||||
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
|
||||
new(
|
||||
instanceActor,
|
||||
instanceActor,
|
||||
sharedScriptLibrary: null!,
|
||||
currentCallDepth: 0,
|
||||
maxCallDepth: 10,
|
||||
askTimeout: TimeSpan.FromSeconds(2),
|
||||
instanceName: "Pump1",
|
||||
logger: NullLogger<ScriptRuntimeContext>.Instance);
|
||||
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
[Fact]
|
||||
public void WaitAsync_Value_AppliesScopeResolution_BeforeSendingRequest()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
// Composed scope "TempSensor" — Resolve("Flag") => "TempSensor.Flag".
|
||||
var acc = new AttributeAccessor(ctx, "TempSensor");
|
||||
|
||||
// Fire-and-forget; the assertion is on the message the actor receives.
|
||||
_ = acc.WaitAsync("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("TempSensor.Flag", req.AttributeName);
|
||||
// The value overload encodes the target via AttributeValueCodec.Encode and
|
||||
// sends a null predicate. bool true encodes to "True" (capital T).
|
||||
Assert.Equal(AttributeValueCodec.Encode(true), req.TargetValueEncoded);
|
||||
Assert.Equal("True", req.TargetValueEncoded);
|
||||
Assert.Null(req.Predicate);
|
||||
Assert.Equal("Pump1", req.InstanceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitAsync_Predicate_AppliesScopeResolution_AndSendsPredicate()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "Motor.TempSensor");
|
||||
|
||||
Func<object?, bool> predicate = _ => true;
|
||||
_ = acc.WaitAsync("Level", predicate, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Motor.TempSensor.Level", req.AttributeName);
|
||||
// The predicate overload sends the delegate and a null encoded target.
|
||||
Assert.Null(req.TargetValueEncoded);
|
||||
Assert.NotNull(req.Predicate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitAsync_RootScope_LeavesKeyBare()
|
||||
{
|
||||
var probe = CreateTestProbe();
|
||||
var ctx = MakeContext(probe.Ref);
|
||||
|
||||
var acc = new AttributeAccessor(ctx, "");
|
||||
_ = acc.WaitAsync("Flag", true, TimeSpan.FromSeconds(30));
|
||||
|
||||
var req = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Flag", req.AttributeName);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user