diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json index 91e07e35..5c69ef1e 100644 --- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json +++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json @@ -10,26 +10,34 @@ { "id": "R2-03-T2", "subject": "Add OpcUaQuality Quality (default Good) to VirtualTagActor.EvaluationResult; bridge passes result.Quality into AttributeValueUpdate; historize maps Bad to 0x80020000u BadInternalError (RED bridge tests then GREEN)", - "status": "pending", - "blockedBy": ["R2-03-T1"] + "status": "completed", + "blockedBy": [ + "R2-03-T1" + ] }, { "id": "R2-03-T3", "subject": "VirtualTagActor failure/throw publishes Bad EvaluationResult once per transition, gated on all dependencyRefs arrived; recovery bypasses value-dedup; update Evaluator_failure_publishes_ScriptLogEntry_warning expectation (turns T1 green)", "status": "pending", - "blockedBy": ["R2-03-T2"] + "blockedBy": [ + "R2-03-T2" + ] }, { "id": "R2-03-T4", "subject": "S13 transition-semantics unit guards: second consecutive failure publishes nothing; same-value recovery republishes Good; evaluator-threw path also degrades", "status": "pending", - "blockedBy": ["R2-03-T3"] + "blockedBy": [ + "R2-03-T3" + ] }, { "id": "R2-03-T5", "subject": "S13 cold-start suppression guards: missing-dep failure publishes no Bad; failure after all deps arrived publishes Bad (inputs-ready gate)", "status": "pending", - "blockedBy": ["R2-03-T3"] + "blockedBy": [ + "R2-03-T3" + ] }, { "id": "R2-03-T6", @@ -47,31 +55,45 @@ "id": "R2-03-T8", "subject": "S12 fix: retry-once on ObjectDisposedException in RoslynVirtualTagEvaluator.Evaluate (re-fetch via GetOrCompile; no retry when _disposed or second hit); update xmldoc (turns T7 green)", "status": "pending", - "blockedBy": ["R2-03-T6", "R2-03-T7"] + "blockedBy": [ + "R2-03-T6", + "R2-03-T7" + ] }, { "id": "R2-03-T9", "subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not", "status": "pending", - "blockedBy": ["R2-03-T3"] + "blockedBy": [ + "R2-03-T3" + ] }, { "id": "R2-03-T10", "subject": "P7 fix: gate the OnApply ClearCompiledScripts on HashSet.SetEquals of desired vs _planByVtag expression sets (ordinal), with explanatory comment (turns T9 green)", "status": "pending", - "blockedBy": ["R2-03-T9"] + "blockedBy": [ + "R2-03-T9" + ] }, { "id": "R2-03-T11", "subject": "Full-suite sweep (Runtime.Tests whole project + RoslynVirtualTagEvaluatorTests + solution build) and STATUS.md / tasks.json bookkeeping", "status": "pending", - "blockedBy": ["R2-03-T4", "R2-03-T5", "R2-03-T8", "R2-03-T10"] + "blockedBy": [ + "R2-03-T4", + "R2-03-T5", + "R2-03-T8", + "R2-03-T10" + ] }, { "id": "R2-03-T12", "subject": "Live-/run gate on docker-dev (rebuild BOTH centrals): author working VT, break it, Client.CLI read shows StatusCode Bad with last value, fix and read Good; P7 no-recompile spot-check on identical redeploy", "status": "pending", - "blockedBy": ["R2-03-T11"] + "blockedBy": [ + "R2-03-T11" + ] } ], "lastUpdated": "2026-07-12" diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index 29d1c909..4e1f6b95 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -4,6 +4,7 @@ using Akka.Event; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Observability; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; @@ -20,7 +21,13 @@ public sealed class VirtualTagActor : ReceiveActor public const string ScriptLogsTopic = "script-logs"; public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc); - public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation); + + /// Result emitted to the parent (the host bridge). carries the + /// OPC UA quality the node should read: for a fresh computed value, + /// when the script failed/timed out (02/S13) — in which case + /// is the last-known value (or null if none). Defaulting to Good keeps + /// every existing construction site source-compatible. + public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good); private readonly string _virtualTagId; private readonly string _scriptId; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 002f1870..4f63c9ae 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -176,16 +176,22 @@ public sealed class VirtualTagHostActor : ReceiveActor return; } + // 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script + // failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the + // sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good. _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( - nodeId, result.Value, OpcUaQuality.Good, result.TimestampUtc)); + nodeId, result.Value, result.Quality, result.TimestampUtc)); // Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so // no parallel map. The historian path key is the SAME folder-scoped NodeId we just published - // to. For a computed value source == server, so both timestamps are the evaluation time. + // to. For a computed value source == server, so both timestamps are the evaluation time. Bad + // results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot + // status — so the historian trend can distinguish a degraded sample from a Good one. if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize) { + var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */; _history.Record(nodeId, new DataValueSnapshot( - result.Value, 0u /* StatusCodes.Good */, result.TimestampUtc, result.TimestampUtc)); + result.Value, status, result.TimestampUtc, result.TimestampUtc)); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index 447db41e..1741028f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -406,6 +406,48 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase bad.TimestampUtc.ShouldBe(ts2); } + /// 02/S13 bridge: a Bad-quality must bridge to + /// an carrying + /// (not the old hard-coded Good). + [Fact] + public void Bad_quality_EvaluationResult_is_bridged_as_Bad() + { + var publish = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator())); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") })); + + var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc); + host.Tell(new VirtualTagActor.EvaluationResult( + "vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad)); + + var update = publish.ExpectMsg(); + update.Quality.ShouldBe(OpcUaQuality.Bad); + update.Value.ShouldBe(42.0); + } + + /// 02/S13 historize parity: a Bad result on a historized plan must record the dormant + /// engine's BadInternalError status (0x80020000u) in the snapshot, not the Good 0u. + [Fact] + public void Historized_Bad_result_is_recorded_with_BadInternalError_status() + { + var publish = CreateTestProbe(); + var writer = new CapturingHistoryWriter(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer)); + + host.Tell(new VirtualTagHostActor.ApplyVirtualTags( + new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) })); + + var ts = new DateTime(2026, 7, 12, 12, 0, 0, DateTimeKind.Utc); + host.Tell(new VirtualTagActor.EvaluationResult( + "vt-1", 42.0, ts, CorrelationId.NewId(), OpcUaQuality.Bad)); + + publish.ExpectMsg(); + AwaitAssert(() => writer.Calls.Count.ShouldBe(1)); + writer.Calls.TryPeek(out var captured).ShouldBeTrue(); + captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity + } + /// Evaluator that returns Ok(42.0) on its first call and Failure thereafter — /// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's /// evaluation thread.