diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index e42b629..de8301d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -143,12 +143,17 @@ public sealed class ScriptedAlarmEngine : IDisposable + string.Join("\n ", compileFailures)); } - // Seed the value cache with current upstream values + subscribe for changes. + // Seed the value cache with current tag values before subscribing. The + // ReadTag calls happen first so that the initial predicate evaluation below + // (startup recovery, decision #14) uses a consistent snapshot. + // Subscriptions are established AFTER _loaded = true so that any synchronous + // initial-push an ITagUpstreamSource delivers from inside SubscribeTag arrives + // when _alarms is fully initialised. Before _loaded = true, a synchronous push + // would race the in-progress state restore and could overwrite the carefully + // seeded cache with a push that has no defined ordering relative to ReadTag. + // (Core.ScriptedAlarms-004) foreach (var path in _alarmsReferencing.Keys) - { _valueCache[path] = _upstream.ReadTag(path); - _upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange)); - } // Restore persisted state, falling back to Fresh where nothing was saved, // then re-derive ActiveState from the current predicate per decision #14. @@ -163,6 +168,14 @@ public sealed class ScriptedAlarmEngine : IDisposable } _loaded = true; + + // Subscribe after _loaded = true and full state restore. If an upstream + // implementation pushes its initial value synchronously from inside + // SubscribeTag, OnUpstreamChange will queue a ReevaluateAsync that acquires + // _evalGate — it will correctly block until LoadAsync releases the gate, then + // re-evaluate against the fully-populated _alarms dict. + foreach (var path in _alarmsReferencing.Keys) + _upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange)); _engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count); // Dispose any previously-created timer before reassigning; a second LoadAsync