From 11534089b9e790c0a7e5ee0f74bcf9af2f7204cd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 09:15:42 -0400 Subject: [PATCH] =?UTF-8?q?docs(siteruntime):=20mark=20WaitAsync=20deferre?= =?UTF-8?q?d=20items=20implemented=20(=C2=A73/=C2=A74.2/=C2=A76)=20+=20fas?= =?UTF-8?q?t-path=20throwing-predicate=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...17-waitfor-attribute-change-helper-spec.md | 15 +++- docs/requirements/Component-InboundAPI.md | 1 + .../InstanceActorWaitForAttributeTests.cs | 72 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md b/docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md index 512bcd78..3ae62aac 100644 --- a/docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md +++ b/docs/plans/2026-06-17-waitfor-attribute-change-helper-spec.md @@ -82,9 +82,13 @@ Task Attributes.WaitAsync(string name, Func predicate, Time // Optional richer overload that also returns the matched value + quality. Task Attributes.WaitForAsync(string name, object? targetValue, TimeSpan timeout); -// record WaitResult(bool Matched, object? Value, string Quality, bool TimedOut); +// record WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut); ``` +> **Status:** IMPLEMENTED. `Attributes.WaitForAsync(...)` returns a `WaitResult` +> (`readonly record struct WaitResult(bool Matched, object? Value, string? Quality, bool TimedOut)` +> in Commons), populated on match (Value + Quality) and `Matched:false, TimedOut:true` on timeout. + Return **bool** (not throw) for the common case — the handshake wants matched/timed-out, not an exception. The value-equality overload is the one the handshake needs and is the one that can also be exposed on the inbound/routed side (§6), because a value serializes and a delegate does not. @@ -159,6 +163,10 @@ public record WaitForAttributeTimeout(string CorrelationId); - Optional: a `quality == "Good"`-only mode (parameter on the request) if a handshake must ignore Bad-quality transients. +> **Status:** IMPLEMENTED as an opt-in `requireGoodQuality` parameter on `WaitAsync`/`WaitForAsync` +> (additive trailing `RequireGoodQuality` field on `WaitForAttributeRequest`, gated at both the +> fast-path and resolve-loop match sites). Default `false` = quality-agnostic (matches on value only). + ### 4.3 `ScriptRuntimeContext` (`src/…/SiteRuntime/Scripts/ScriptRuntimeContext.cs`) - **Thread the script timeout token in.** Add a `CancellationToken scriptTimeoutToken` constructor parameter (today only `_askTimeout` is available to helpers; the per-script `cts.Token` is **not** @@ -228,6 +236,11 @@ so the routed form takes the encoded target value (the predicate overload stays optional: the receiver handshake runs **inside** the template script (site-local), so §3–§5 alone fully cover the DELMIA/MES use case. +> **Status:** IMPLEMENTED. `Route.To(code).WaitForAttribute(name, targetValue, timeout)` is wired +> end-to-end (`RouteToWaitForAttributeRequest/Response` → `IInstanceRouter` → `CommunicationService` +> → `SiteCommunicationActor` → `DeploymentManagerActor` → `InstanceActor`), value-equality only +> across the wire. NOT wired into the CentralUI Test-Run sandbox — that remains a follow-up. + --- ## 7. Acceptance criteria diff --git a/docs/requirements/Component-InboundAPI.md b/docs/requirements/Component-InboundAPI.md index 7c6dd554..26e1ab04 100644 --- a/docs/requirements/Component-InboundAPI.md +++ b/docs/requirements/Component-InboundAPI.md @@ -189,6 +189,7 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a - `Route.To("instanceUniqueCode").GetAttributes("attr1", "attr2", ...)` — Read multiple attribute values in a **single call**, returned as a dictionary of name-value pairs. - `Route.To("instanceUniqueCode").SetAttribute("attributeName", value)` — Write a single attribute value on a specific instance at any site. - `Route.To("instanceUniqueCode").SetAttributes(dictionary)` — Write multiple attribute values in a **single call**, accepting a dictionary of name-value pairs. +- `Route.To("instanceUniqueCode").WaitForAttribute("attributeName", targetValue, timeout)` — Wait, event-driven, until an attribute on a specific instance at any site reaches `targetValue` (value-equality only across the wire), bounded by `timeout`. Returns `true` if matched within the timeout, `false` if it timed out. The cluster call is bounded by the wait timeout rather than the generic integration timeout. #### Input/Output - **Input parameters** are available as defined in the method definition. diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs index ca80f112..ca13817d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorWaitForAttributeTests.cs @@ -565,6 +565,78 @@ public class InstanceActorWaitForAttributeTests : TestKit, IDisposable ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + // ── 8b. CRITICAL 2 (fast-path): throwing predicate on already-held value ── + + /// + /// CRITICAL 2 regression (fast-path analogue of + /// ): + /// a predicate that THROWS is registered against an attribute that ALREADY holds a + /// value, so the fast-path test(current) runs and throws. The actor must + /// (a) reply a non-matched WaitForAttributeResponse with a non-null + /// ErrorMessage (predicate-threw), (b) stay alive/responsive (it answers a + /// subsequent GetAttributeRequest), and (c) NOT register the waiter — there + /// is no later/second reply even after a value change on that attribute (the + /// fast-path guard returns WITHOUT scheduling a timeout or storing the waiter). + /// + [Fact] + public void WaitForAttribute_ThrowingPredicate_FastPath_RepliesError_NoRegistration_ActorStaysAlive() + { + const string tag = "ns=3;s=State"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump1", + Attributes = + [ + new ResolvedAttribute + { + // Present from construction so the fast-path TryGetValue HITS and + // the predicate runs on the current value (and throws). + CanonicalName = "State", Value = "init", DataType = "String", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => new InstanceActor( + "Pump1", + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedScriptLibrary, + null, + _options, + NullLogger.Instance, + dcl.Ref))); + + dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + + // Predicate THROWS unconditionally — the current value "init" is already + // present, so the fast-path test(current) executes it and throws. + Func boom = _ => throw new InvalidOperationException("kaboom"); + actor.Tell(new WaitForAttributeRequest( + "wfa-fp-throw", "Pump1", "State", + null, boom, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow)); + + // (a) Non-matched error reply (predicate-threw), guarded on the fast-path. + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("wfa-fp-throw", response.CorrelationId); + Assert.False(response.Matched); + Assert.False(response.TimedOut); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("Wait predicate threw", response.ErrorMessage); + + // (b) The actor stayed alive and responsive: a follow-up request resolves. + actor.Tell(new GetAttributeRequest("get-after-fp", "Pump1", "State", DateTimeOffset.UtcNow)); + var get = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("init", get.Value); + + // (c) The waiter was NOT registered (no timeout scheduled): driving a value + // change on "State" produces NO further WaitForAttributeResponse. + actor.Tell(new TagValueUpdate("PLC", tag, "ready", QualityCode.Good, DateTimeOffset.UtcNow)); + ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + // ── 9. Quality-gated ("Good"-only) matching (spec §4.2) ────────────────── ///