From ff4e210a278d5f5d1a9118fe80a7a12c356466da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:46:29 -0400 Subject: [PATCH] fix(opcua): replay kept scripted conditions after a rebuild; fit window values onto re-shaped nodes (Lane B fix round 3) N3 (critical): the node manager carries only NATIVE conditions' state over a full rebuild; scripted conditions come back fresh and rely on the engine's re-assert, which since the off-thread plan is handled in the window and lands on the discarded instance. On a rebuild the condition replay scope is now AddedConditions plus every enabled scripted condition in the new composition (native kept conditions stay carried-over, not replayed). The test sink again models the real rebuild: it clears non-native condition state only. N4: a surgical in-place shape edit (DataType / IsArray / ArrayLength) resets the raw node and its UNS mirror to BadWaitingForInitialData without ensuring them, so the window's value was not replayed and the two NodeIds disagreed. The applier records re-shaped nodes (MaterialisationRecording.Reshaped); their deferred value replays only once fitted to the new shape (VariableShape.TryFit: as is, or converted invariantly; never an old-typed value - WriteValues assigns the value verbatim with no type check), otherwise it is dropped at Debug and the node keeps waiting. Status-only entries are not replayed onto a re-shaped node. Minors: m1/m2 documented on _replayScope (degraded-path only); m3 corrected the false carry-over premise in the scope doc (the ScriptedAlarmHostActor ordering remark is outside this lane - F8); m4 the replay Debug log reports alarm-state and alarm-quality counts again. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 145 ++++++++++-- .../OpcUa/OpcUaPublishActor.cs | 73 +++++- .../VariableShapeTests.cs | 64 +++++ .../OpcUaPublishActorDeferredWriteTests.cs | 221 ++++++++++++++++-- 4 files changed, 457 insertions(+), 46 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index bb0f2752..961319f5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -219,6 +219,9 @@ public sealed class AddressSpaceApplier ok = false; } if (!ok) { allApplied = false; break; } + RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId, + d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength, + d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); } // v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's // IDENTITY (2026-08-15 review I2). @@ -270,6 +273,9 @@ public sealed class AddressSpaceApplier updated = false; } if (!updated) { allApplied = false; break; } + RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId, + d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength, + d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength); continue; } @@ -1156,30 +1162,49 @@ public sealed class AddressSpaceApplier try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); - _ensuredVariables?.Add((realm, nodeId)); + _recording?.Ensured.Add((realm, nodeId)); return true; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } } - /// While non-null, every variable this applier ensures (realm, NodeId). See . - private HashSet<(AddressSpaceRealm, string)>? _ensuredVariables; - - /// - /// Starts recording every variable the following materialise passes ensure, until - /// . The publish actor scopes the replay of writes it deferred while a - /// plan was in flight to these (a surgical apply ensures only its delta), so a variable the apply kept is not - /// rewritten. Single-caller (the publish actor's thread), like the rest of the apply. - /// - /// The live set the recording fills. - public IReadOnlySet<(AddressSpaceRealm, string)> BeginRecordingEnsuredVariables() + /// Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness / + /// array length): the node manager drops such a node's value to BadWaitingForInitialData. + private void RecordReshape( + AddressSpaceRealm realm, string nodeId, + string previousDataType, bool previousIsArray, uint? previousArrayLength, + string dataType, bool isArray, uint? arrayLength) { - _ensuredVariables = new HashSet<(AddressSpaceRealm, string)>(); - return _ensuredVariables; + if (_recording is null) return; + if (string.Equals(previousDataType, dataType, StringComparison.Ordinal) + && previousIsArray == isArray + && (!isArray || previousArrayLength == arrayLength)) + { + return; + } + + _recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength); } - /// Stops the recording started (the returned set is kept). - public void EndRecordingEnsuredVariables() => _ensuredVariables = null; + /// The recording in progress, or null. See . + private MaterialisationRecording? _recording; + + /// + /// Starts recording what the following apply and materialise passes do to VARIABLES, until + /// : every variable ensured, and every variable an in-place attribute update + /// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan + /// was in flight to these — a variable the apply kept took the live write already, and rewriting it would + /// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply. + /// + /// The live recording. + public MaterialisationRecording BeginRecording() + { + _recording = new MaterialisationRecording(); + return _recording; + } + + /// Stops the recording started (the returned recording is kept). + public void EndRecording() => _recording = null; // The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the // whole delta classification is one pure, table-testable unit; the applier references them there (and the @@ -1293,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome( bool RebuildCalled, bool RebuildFailed = false, int FailedNodes = 0); + +/// What one apply did to variables (see ). +public sealed class MaterialisationRecording +{ + /// Every variable an EnsureVariable was issued for, as (realm, NodeId). + public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new(); + + /// Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape. + public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new(); +} + +/// A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length. +/// The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits). +/// Whether the node is a one-dimensional array. +/// The fixed array length, when known (null / 0 ⇒ any length). +public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength) +{ + /// + /// Fits to this shape for a write onto a re-shaped node: unchanged when it already + /// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an + /// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that). + /// A null value always fits (a status annotation carries none). + /// + /// The value to fit. + /// The fitted value, when this returns true. + /// True when the value fits (as is, or converted). + public bool TryFit(object? value, out object? fitted) + { + fitted = null; + if (value is null) return true; + var element = ElementType(DataType); + if (!IsArray) + { + if (value is Array) return false; + return TryConvert(value, element, out fitted); + } + + if (value is not Array array || value is string) return false; + if (ArrayLength is > 0 && array.Length != ArrayLength) return false; + if (element is null) { fitted = value; return true; } + var result = Array.CreateInstance(element, array.Length); + for (var i = 0; i < array.Length; i++) + { + if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false; + result.SetValue(item, i); + } + + fitted = result; + return true; + } + + private static bool TryConvert(object? value, Type? target, out object? converted) + { + converted = value; + if (value is null || target is null || target.IsInstanceOfType(value)) return true; + if (value is not IConvertible) return false; + try + { + converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + converted = null; + return false; + } + } + + /// The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set); + /// null ⇒ BaseDataType, where any value fits. + private static Type? ElementType(string dataType) => dataType switch + { + "Boolean" => typeof(bool), + "SByte" => typeof(sbyte), + "Byte" => typeof(byte), + "Int16" => typeof(short), + "UInt16" => typeof(ushort), + "Int32" => typeof(int), + "UInt32" => typeof(uint), + "Int64" => typeof(long), + "UInt64" => typeof(ulong), + "Float" => typeof(float), + "Double" => typeof(double), + "String" => typeof(string), + "DateTime" => typeof(DateTime), + _ => null, + }; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 94f2f24d..a1667540 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -187,8 +187,21 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// /// Variables: after a FULL rebuild every one (each was recreated, its live write landed on the /// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them). - /// Conditions: those in the applied composition but not the previous one — on a rebuild too, because the - /// node manager carries a kept condition's state over to its re-materialised instance. + /// Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to + /// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape + /// (); a value that does not fit is dropped and the node left waiting. + /// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment. + /// Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO + /// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over + /// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition + /// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not + /// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is + /// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it. + /// Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance + /// _lastApplied, so the next apply counts its conditions as added again and may replay onto them; + /// and the empty-plan repair of a degraded composition scopes no condition (the composition is + /// unchanged), so a condition the repair newly places gets no window state — writes to it between the + /// two applies were already dropped before this replay existed. /// /// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through /// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.) @@ -198,10 +211,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private sealed record ReplayScope( bool AllVariables, IReadOnlySet<(AddressSpaceRealm, string)> Variables, + IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped, IReadOnlySet<(AddressSpaceRealm, string)> Conditions) { private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>(); - public static readonly ReplayScope Nothing = new(false, None, None); + public static readonly ReplayScope Nothing = + new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None); } /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in @@ -851,8 +866,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private void ReplayDeferredWrites(ReplayScope scope) { if (_deferred.IsEmpty) return; - var (values, statuses) = _deferred.VariableWrites( - key => scope.AllVariables || scope.Variables.Contains(key)); + var (allValues, allStatuses) = _deferred.VariableWrites( + key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key)); + var values = new List(allValues.Count); + var statuses = new List(allStatuses.Count); + var unfit = 0; + foreach (var v in allValues) + { + if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; } + if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; } + unfit++; + _log.Debug( + "OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value", + v.Realm, v.NodeId, shape.DataType, shape.IsArray); + } + + foreach (var st in allStatuses) + { + // A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the + // node's honest state is BadWaitingForInitialData until a value of the new shape arrives. + if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; } + statuses.Add(st); + } if (values.Count > 0) { try { _sink.WriteValues(values); } @@ -865,22 +900,27 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); } } + var alarms = 0; foreach (var alarm in _deferred.Alarms) { if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue; + alarms++; try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); } } + var qualities = 0; foreach (var quality in _deferred.AlarmQualities) { if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue; + qualities++; try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); } } - _log.Debug("OpcUaPublish: replayed {Values} value and {Statuses} status writes (of {Held} held) deferred during a rebuild plan", - values.Count, statuses.Count, _deferred.Count); + _log.Debug( + "OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan", + values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit); } /// @@ -1019,17 +1059,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready) { var previous = _lastApplied; - var ensured = _applier!.BeginRecordingEnsuredVariables(); + var recording = _applier!.BeginRecording(); try { var result = ApplyPlanCore(ready, out var rebuilt); - // What this apply created (see _replayScope). A throwing pass still scopes to what it did create. - _replayScope = new ReplayScope(rebuilt, ensured, AddedConditions(previous, ready.Composition)); + // What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did. + var conditions = AddedConditions(previous, ready.Composition); + if (rebuilt) + { + // Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only + // NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert + // (or any transition) handled in the window landed on the discarded instance, so every kept scripted + // condition must be replayed onto its new one. Native kept conditions stay "added only". + foreach (var a in ready.Composition.EquipmentScriptedAlarms) + if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId)); + } + + _replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions); return result; } finally { - _applier.EndRecordingEnsuredVariables(); + _applier.EndRecording(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs new file mode 100644 index 00000000..52e6daff --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs @@ -0,0 +1,64 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Lane B review N4 — decides whether a value deferred during a plan window may +/// be replayed onto a node an in-place edit re-shaped: never an old-typed value the new DataType contradicts. +/// +public sealed class VariableShapeTests +{ + [Fact] + public void A_value_of_the_new_type_fits_unchanged() + { + new VariableShape("Int32", false, null).TryFit(7, out var fitted).ShouldBeTrue(); + fitted.ShouldBe(7); + } + + [Fact] + public void A_convertible_old_typed_value_is_converted_to_the_new_type() + { + new VariableShape("Int32", false, null).TryFit(5.0f, out var fitted).ShouldBeTrue(); + fitted.ShouldBeOfType().ShouldBe(5); + new VariableShape("Double", false, null).TryFit((short)3, out var d).ShouldBeTrue(); + d.ShouldBeOfType().ShouldBe(3d); + } + + [Theory] + [InlineData("Int32", "fast")] // not a number + [InlineData("Byte", 300)] // overflow + [InlineData("DateTime", 12)] // no conversion + public void A_value_the_new_type_cannot_hold_does_not_fit(string dataType, object value) + { + new VariableShape(dataType, false, null).TryFit(value, out var fitted).ShouldBeFalse(); + fitted.ShouldBeNull(); + } + + [Fact] + public void Scalar_and_array_never_cross() + { + new VariableShape("Int32", false, null).TryFit(new[] { 1, 2 }, out _).ShouldBeFalse(); + new VariableShape("Int32", true, 2).TryFit(1, out _).ShouldBeFalse(); + new VariableShape("String", true, null).TryFit("ab", out _).ShouldBeFalse("a string is not an array of strings"); + } + + [Fact] + public void An_array_is_converted_element_wise_and_its_fixed_length_enforced() + { + new VariableShape("Int16", true, 3).TryFit(new[] { 1, 2, 3 }, out var fitted).ShouldBeTrue(); + fitted.ShouldBeOfType().ShouldBe(new short[] { 1, 2, 3 }); + new VariableShape("Int16", true, 4).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeFalse("wrong length"); + new VariableShape("Int16", true, null).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeTrue("no fixed length"); + new VariableShape("Byte", true, null).TryFit(new[] { 1, 999 }, out _).ShouldBeFalse("an element overflows"); + } + + [Fact] + public void Null_and_unknown_types_always_fit() + { + new VariableShape("Int32", false, null).TryFit(null, out var n).ShouldBeTrue(); + n.ShouldBeNull(); + new VariableShape("Structure", false, null).TryFit("anything", out var any).ShouldBeTrue(); + any.ShouldBe("anything"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs index 82bc6d8a..2ab304a8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -277,13 +277,11 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase /// /// N2 — a condition the plan KEEPS took the live write already; replaying it would roll back an operator's - /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. Driven over - /// both apply shapes: a surgical pure-add, and a full rebuild (which carries the condition's state over). + /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. The surgical + /// apply shape; the rebuild shapes are the two tests below (scripted: replayed; native: carried over). /// - [Theory] - [InlineData(false)] - [InlineData(true)] - public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay(bool rebuild) + [Fact] + public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay() { var sink = new ExistenceSink(); using var gate = new ManualResetEventSlim(true); @@ -293,8 +291,8 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase sink.ConditionExists("al-1").ShouldBeTrue(); gate.Reset(); - // A surgical pure-add (new raw tags) or a full rebuild (an equipment rename) that keeps al-1. - actor.Tell(Rebuild(rebuild ? AlarmArtifact(equipmentName: "Eq-renamed") : AlarmArtifact(rawTags: 2))); + // A surgical pure-add (new raw tags) that keeps al-1. + actor.Tell(Rebuild(AlarmArtifact(rawTags: 2))); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // the live write landed sink.AcknowledgeOutOfBand("al-1"); // an operator acks in the window @@ -302,7 +300,7 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase gate.Set(); ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); - sink.SawRebuild.ShouldBe(rebuild, "non-vacuity: the intended apply shape ran"); + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply"); sink.AlarmStateOf("al-1")!.Acknowledged.ShouldBeTrue("the operator's ack must survive the apply"); sink.AlarmWritesTo("al-1").ShouldBe(1, "no replayed write (and so no extra Part 9 event) on a kept condition"); } @@ -422,6 +420,149 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase second.Degraded.ShouldBeTrue(); } + + /// + /// N3 (i) — a full rebuild recreates every KEPT SCRIPTED condition fresh (only native conditions are carried + /// over), so the engine's re-assert handled in the window — landing on the discarded instance — must be + /// replayed onto the new one, or the active alarm reads Normal until its next transition (#487). + /// + [Fact] + public void A_rebuild_replays_the_window_state_onto_every_kept_scripted_condition() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact())); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + gate.Reset(); + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed"))); // ChangedEquipment ⇒ full rebuild, al-1 kept + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // landed live on the old instance + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.AlarmStateOf("al-1").ShouldNotBeNull("the re-assert must reach the recreated condition") + .Active.ShouldBeTrue(); + } + + /// + /// N3 (ii) — the native counterpart: a kept NATIVE condition is restored from the node manager's carry-over + /// (which already holds the live write and the operator's ack), so it is not replayed — the ack survives and no + /// extra write lands. + /// + [Fact] + public void A_rebuild_does_not_replay_onto_a_kept_native_condition() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact(nativeAlarm: true))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.ConditionExists(NativeAlarm).ShouldBeTrue(); + + gate.Reset(); + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", nativeAlarm: true))); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(NativeAlarm, ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Raw)); + AwaitAssert(() => sink.AlarmWritesTo(NativeAlarm).ShouldBe(1), PlanBudget); + sink.AcknowledgeOutOfBand(NativeAlarm); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.AlarmStateOf(NativeAlarm)!.Acknowledged.ShouldBeTrue("the carried-over ack survives"); + sink.AlarmWritesTo(NativeAlarm).ShouldBe(1, "no replayed write on a carried-over native condition"); + } + + /// + /// N4 — a surgical in-place DataType edit resets the raw node AND its UNS mirror (the node manager drops an + /// old-typed value), and neither is ensured by the apply. The window's value is replayed onto both, fitted to + /// the new type, so the two NodeIds of one value agree. + /// + [Fact] + public void A_surgical_shape_edit_replays_the_window_value_fitted_onto_the_raw_node_and_its_mirror() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(ShapeArtifact("Float"))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists(RawSpeed).ShouldBeTrue(); + sink.Exists(UnsSpeed).ShouldBeTrue("non-vacuity: the mirror exists"); + + gate.Reset(); + actor.Tell(Rebuild(ShapeArtifact("Int32"))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(RawSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(UnsSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Uns), + ])); + AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe(5.0f), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical in-place edit"); + sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset"); + sink.ReshapedNodes.ShouldContain(UnsSpeed, "non-vacuity: the mirror was reset"); + var raw = sink.ValueOf(RawSpeed).ShouldNotBeNull(); + raw.Value.ShouldBeOfType().ShouldBe(5); + sink.ValueOf(UnsSpeed).ShouldBe(raw, "the raw node and its UNS mirror agree"); + } + + /// N4 — a window value that cannot be fitted to the new shape is never written: both NodeIds stay + /// value-less (BadWaitingForInitialData) until the driver's next value. + [Fact] + public void A_surgical_shape_edit_never_replays_a_value_that_does_not_fit_the_new_shape() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(ShapeArtifact("String"))); + ExpectMsg(PlanBudget); + + gate.Reset(); + actor.Tell(Rebuild(ShapeArtifact("Int32"))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(RawSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(UnsSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Uns), + ])); + AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe("fast"), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset"); + sink.ValueOf(RawSpeed).ShouldBeNull("\"fast\" is not an Int32 — the node keeps waiting"); + sink.ValueOf(UnsSpeed).ShouldBeNull(); + } + + private const string NativeAlarm = "Plant/Modbus/dev1/Hi"; + private const string RawSpeed = "Plant/Modbus/dev1/Speed"; + private const string UnsSpeed = "Area/Line/Eq/Speed"; + + /// One raw tag (Speed) of and one UNS reference to it under Area/Line/Eq. + private static byte[] ShapeArtifact(string dataType) => + JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, + UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, + Equipment = new[] { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" } }, + RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = new[] { new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Speed", DataType = dataType, AccessLevel = 0, TagConfig = "{}" } }, + UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed", DisplayNameOverride = (string?)null } }, + ScriptedAlarms = Array.Empty(), + }); + private static AlarmConditionSnapshot ActiveUnacked(uint quality) => new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high", quality); @@ -454,7 +595,7 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0) => + private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0, bool nativeAlarm = false) => JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, @@ -463,13 +604,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" }, }, - RawFolders = rawTags == 0 + RawFolders = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, - DriverInstances = rawTags == 0 + DriverInstances = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, - Devices = rawTags == 0 + Devices = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, TagGroups = Array.Empty(), @@ -477,7 +618,16 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", DataType = "Float", AccessLevel = 0, TagConfig = "{}", - }).ToArray(), + }).Concat(nativeAlarm + ? new object[] + { + new + { + TagId = "t-hi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Hi", DataType = "Boolean", + AccessLevel = 0, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", + }, + } + : Array.Empty()).ToArray(), ScriptedAlarms = new[] { new @@ -615,9 +765,18 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { - lock (_gate) { _applyClosed = false; _conditions.Add(alarmNodeId); } + lock (_gate) + { + _applyClosed = false; + _conditions.Add(alarmNodeId); + if (isNative) _nativeConditions.Add(alarmNodeId); else _nativeConditions.Remove(alarmNodeId); + } } + private readonly HashSet _nativeConditions = new(); + private readonly Dictionary _shapes = new(); + public ConcurrentQueue ReshapedNodes { get; } = new(); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { lock (_gate) _applyClosed = false; @@ -625,20 +784,28 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { - lock (_gate) { _applyClosed = false; _variables.Add(variableNodeId); } + lock (_gate) + { + _applyClosed = false; + if (_variables.Add(variableNodeId)) _shapes[variableNodeId] = (dataType, isArray, arrayLength); + } } public void RebuildAddressSpace() { lock (_gate) { - // Variables are recreated empty; a condition's state is captured and carried over to its - // re-materialised instance (the node manager's rebuild carry-over). + // OtOpcUaNodeManager.RebuildAddressSpace: variables are recreated empty; only NATIVE conditions' + // state is captured and carried over to the re-materialised instance — a scripted condition comes + // back fresh (its engine re-asserts it). _applyClosed = false; _sawRebuild = true; _variables.Clear(); + _shapes.Clear(); _conditions.Clear(); _values.Clear(); + foreach (var id in _alarmStates.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmStates.Remove(id); + foreach (var id in _alarmQualities.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmQualities.Remove(id); } } @@ -652,7 +819,23 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) => true; + /// OtOpcUaNodeManager.UpdateTagAttributes: a SHAPE change (DataType / array-ness / length) drops the + /// node's value (BadWaitingForInitialData); any other attribute edit leaves it. + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) + { + lock (_gate) + { + if (!_variables.Contains(variableNodeId)) return false; + if (_shapes.GetValueOrDefault(variableNodeId) != (dataType, isArray, arrayLength)) + { + _shapes[variableNodeId] = (dataType, isArray, arrayLength); + _values.Remove(variableNodeId); + ReshapedNodes.Enqueue(variableNodeId); + } + + return true; + } + } public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true; public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { lock (_gate) return _variables.Remove(variableNodeId); } public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { lock (_gate) return _conditions.Remove(alarmNodeId); }