diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index 13c935a5..23a628dd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -206,6 +206,13 @@ public sealed record EquipmentScriptedAlarmPlan( /// (an interface-typed list) BY REFERENCE, flagging every alarm as /// "changed" on every parse (fresh list instances). Compare it element-wise so a no-op redeploy /// diffs empty (mirrors ). + /// + /// DependencyRefs equality is order-sensitive (SequenceEqual). + /// is the canonical, deterministic + /// producer of that order (predicate ctx.GetTag reads first, then first-seen message + /// template tokens). Downstream byte-parity between the live composer and the artifact-decode + /// mirror depends on both sides calling MergeAlarmDependencyRefs with identical inputs. + /// public bool Equals(EquipmentScriptedAlarmPlan? other) => other is not null && ScriptedAlarmId == other.ScriptedAlarmId && @@ -419,37 +426,37 @@ public static class Phase7Composer // template tokens; the reserved {{equip}} double-brace form is excluded). Enabled is carried // (never dropped) — the runtime host decides whether to host a disabled alarm. Ordered by // EquipmentId then ScriptedAlarmId so the upcoming artifact byte-parity test is reliable. - var equipmentScriptedAlarms = scriptedAlarms + // + // Eager foreach (not lazy LINQ Select) so the Trace.TraceWarning fires exactly once per + // compose call; a lazy select would re-fire on every re-enumeration of the LINQ chain. + var equipmentScriptedAlarms = new List(); + foreach (var a in scriptedAlarms .OrderBy(a => a.EquipmentId, StringComparer.Ordinal) - .ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal) - .Select(a => + .ThenBy(a => a.ScriptedAlarmId, StringComparer.Ordinal)) + { + if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s)) { - if (!scriptsById.TryGetValue(a.PredicateScriptId, out var s)) - { - Trace.TraceWarning( - "Phase7Composer: scripted alarm '{0}' (equipment '{1}') references predicate " + - "script '{2}' which is not in the supplied scripts — skipping.", - a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId); - return null; - } - var source = s.SourceCode; - return new EquipmentScriptedAlarmPlan( - ScriptedAlarmId: a.ScriptedAlarmId, - EquipmentId: a.EquipmentId, - Name: a.Name, - AlarmType: a.AlarmType, - Severity: a.Severity, - MessageTemplate: a.MessageTemplate, - PredicateScriptId: a.PredicateScriptId, - PredicateSource: source, - DependencyRefs: MergeAlarmDependencyRefs(source, a.MessageTemplate), - HistorizeToAveva: a.HistorizeToAveva, - Retain: a.Retain, - Enabled: a.Enabled); - }) - .Where(p => p is not null) - .Select(p => p!) - .ToList(); + Trace.TraceWarning( + "Phase7Composer: scripted alarm '{0}' (equipment '{1}') references predicate " + + "script '{2}' which is not in the supplied scripts — skipping.", + a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId); + continue; + } + var source = s.SourceCode; + equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan( + ScriptedAlarmId: a.ScriptedAlarmId, + EquipmentId: a.EquipmentId, + Name: a.Name, + AlarmType: a.AlarmType, + Severity: a.Severity, + MessageTemplate: a.MessageTemplate, + PredicateScriptId: a.PredicateScriptId, + PredicateSource: source, + DependencyRefs: MergeAlarmDependencyRefs(source, a.MessageTemplate), + HistorizeToAveva: a.HistorizeToAveva, + Retain: a.Retain, + Enabled: a.Enabled)); + } return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs index 5302fe40..e8ac68d7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs @@ -32,6 +32,8 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource // AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too. private const uint StatusBad = 0x80000000u; + // Intentionally never pruned: cold-start semantics depend on retained last values so + // ReadTag can answer synchronously for any path that has ever been pushed. private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary>> _observers @@ -72,7 +74,10 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource if (_observers.TryGetValue(path, out var observers)) { foreach (var observer in observers) - observer(path, snapshot); + { + try { observer(path, snapshot); } + catch { /* one misbehaving observer must not silence the rest */ } + } } } @@ -91,6 +96,12 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource } /// + /// + /// No-replay contract: a new subscriber does NOT receive a synthetic initial + /// notification for the current cached value. Callers that need the current value must + /// call immediately after subscribing. The engine's cold-start path + /// (startup-recovery + read-cache-refill) already does this. + /// public IDisposable SubscribeTag(string path, Action observer) { ArgumentNullException.ThrowIfNull(path); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs index baf697fb..f4d56349 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs @@ -76,6 +76,14 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore } /// + /// + /// Concurrency assumption: saves for a given alarmId are serialized by the + /// owning host actor (one actor owns the engine per equipment), mirroring + /// EfAlarmActorStateStore. The check-then-insert pattern is therefore safe under + /// that guarantee — two concurrent inserts for the same alarm cannot occur in the live + /// runtime. The catch handles the edge case of a + /// racing concurrent restart during crash recovery. + /// public async Task SaveAsync(AlarmConditionState state, CancellationToken ct) { using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); @@ -85,6 +93,8 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore if (row is null) { + // Required members are set here to satisfy the compiler; ApplyState is the single + // source of truth for all columns and immediately overwrites these values below. row = new ScriptedAlarmState { ScriptedAlarmId = state.AlarmId, @@ -151,15 +161,15 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore AlarmId: row.ScriptedAlarmId, Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal) ? AlarmEnabledState.Disabled - : AlarmEnabledState.Enabled, + : AlarmEnabledState.Enabled, // unknown string → Enabled (safe default) // Active is not persisted — the engine re-derives it from the predicate at startup. Active: AlarmActiveState.Inactive, Acked: string.Equals(row.AckedState, "Acknowledged", StringComparison.Ordinal) ? AlarmAckedState.Acknowledged - : AlarmAckedState.Unacknowledged, + : AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default) Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal) ? AlarmConfirmedState.Confirmed - : AlarmConfirmedState.Unconfirmed, + : AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default) Shelving: new ShelvingState(MapShelvingFromColumn(row.ShelvingState), row.ShelvingExpiresUtc), // No transition column — UpdatedAtUtc carries the last transition timestamp. LastTransitionUtc: row.UpdatedAtUtc, @@ -194,7 +204,7 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore { "OneShotShelved" => ShelvingKind.OneShot, "TimedShelved" => ShelvingKind.Timed, - _ => ShelvingKind.Unshelved, + _ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default) }; private static string SerializeComments(ImmutableList comments) @@ -202,6 +212,8 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore if (comments.IsEmpty) return "[]"; var dtos = comments.Select(c => new CommentDto { + // AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip; + // the engine always creates AlarmComment instances with Utc kind. TimestampUtc = c.TimestampUtc, User = c.User, Kind = c.Kind,