From fc0d43a3dc483e4c96c425c15b2850b7846d5cb0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 15:22:26 -0400 Subject: [PATCH] refactor(scripted-alarms): retire orphaned ScriptedAlarmActor + F9b evaluator (T11) --- .../Engines/IAlarmActorStateStore.cs | 54 ---- .../Engines/IScriptedAlarmEvaluator.cs | 47 ---- .../Engines/RoslynScriptedAlarmEvaluator.cs | 124 --------- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 8 - .../ScriptedAlarms/EfAlarmActorStateStore.cs | 121 --------- .../EfAlarmConditionStateStore.cs | 16 +- .../ScriptedAlarms/ScriptedAlarmActor.cs | 254 ------------------ .../RoslynScriptedAlarmEvaluatorTests.cs | 159 ----------- .../ScriptedAlarms/ScriptedAlarmActorTests.cs | 176 ------------ .../ScriptedAlarmStatePersistenceTests.cs | 157 ----------- 10 files changed, 7 insertions(+), 1109 deletions(-) delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmStatePersistenceTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs deleted file mode 100644 index 56b195a4..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Commons.Engines; - -/// -/// Persistence seam for ScriptedAlarmActor's in-memory state across actor restarts. -/// Captures only the slice the actor's 3-state machine needs (Inactive / Active / -/// Acknowledged + last transition + last-ack user). The fuller GxP audit trail -/// ('s Comments/Confirmed/Shelving) -/// stays in the production engine binding — this seam is the small surface the actor -/// consumes directly. -/// -public interface IAlarmActorStateStore -{ - /// Loads the persisted state snapshot for an alarm actor. - /// The alarm identifier. - /// Cancellation token. - /// The alarm state snapshot if found; null if the alarm has no persisted state. - Task LoadAsync(string alarmId, CancellationToken ct); - /// Saves the alarm actor state snapshot. - /// The state snapshot to persist. - /// Cancellation token. - Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct); -} - -/// Persisted slice of ScriptedAlarmActor's state. Active is NOT persisted — -/// it re-derives from the evaluator on startup per Phase 7 decision #14. State here -/// distinguishes Acknowledged vs not-yet-acknowledged for cases where the actor came up -/// Active and operator interaction had already happened. -/// Matches ScriptedAlarm.ScriptedAlarmId. -/// Inactive / Active / Acknowledged — the actor's 3-state enum, projected to string. -/// When the actor last transitioned. -/// Who acknowledged most recently. Null when never acked. -public sealed record AlarmActorStateSnapshot( - string AlarmId, - string State, - DateTime LastTransitionUtc, - string? LastAckUser); - -/// No-op default. Bound when no production store is configured (tests, smoke runs). -/// Load returns null → actor boots Inactive; Save is a no-op so state doesn't leak. -public sealed class NullAlarmActorStateStore : IAlarmActorStateStore -{ - public static readonly NullAlarmActorStateStore Instance = new(); - private NullAlarmActorStateStore() { } - /// Always returns null, indicating no persisted state. - /// The alarm identifier (unused). - /// Cancellation token (unused). - public Task LoadAsync(string alarmId, CancellationToken ct) => - Task.FromResult(null); - /// Completes immediately without persisting anything. - /// The state snapshot (ignored). - /// Cancellation token (unused). - public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) => - Task.CompletedTask; -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs deleted file mode 100644 index 6ad1464e..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Commons.Engines; - -/// -/// Abstraction over the scripted-alarm predicate engine. Production binds this to a -/// wrapper around ScriptedAlarmEngine from Core.ScriptedAlarms; default -/// binding is which keeps the alarm in its -/// current state (so an unconfigured node never spuriously alarms). -/// -public interface IScriptedAlarmEvaluator -{ - /// Evaluates an alarm predicate against the provided dependencies. - /// The unique identifier of the alarm being evaluated. - /// The predicate expression to evaluate. - /// Read-only dictionary of variable names to values for predicate evaluation. - /// Result containing success flag, alarm active state, and optional failure reason. - ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies); -} - -/// Result of one alarm-predicate evaluation. Active is only meaningful when -/// Success is true; on failure the caller should keep the prior state and log Reason. -public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason) -{ - /// Creates a successful alarm evaluation result with the given active state. - /// Whether the alarm condition is active. - /// A successful evaluation result. - public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null); - - /// Creates a failed alarm evaluation result with the given reason. - /// Description of the evaluation failure cause. - /// A failed evaluation result. - public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason); -} - -/// Default that always returns Active = false, Success = true. Safe no-op: -/// no alarm fires when no real engine is bound. -public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator -{ - public static readonly NullScriptedAlarmEvaluator Instance = new(); - private NullScriptedAlarmEvaluator() { } - /// Returns an inactive alarm result for every evaluation (safe no-op behavior). - /// The alarm identifier (ignored). - /// The predicate expression (ignored). - /// The variable dependencies (ignored). - /// Always returns an inactive alarm result. - public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies) - => ScriptedAlarmEvalResult.Ok(active: false); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs deleted file mode 100644 index 8a247381..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.OtOpcUa.Commons.Engines; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; -using ZB.MOM.WW.OtOpcUa.Core.Scripting; -using SerilogLogger = Serilog.ILogger; - -namespace ZB.MOM.WW.OtOpcUa.Host.Engines; - -/// -/// F9b — production binding. Compiles each unique -/// predicate once via against -/// and caches the resulting evaluator. Predicates are -/// pure functions returning bool: -/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation. -/// -/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface -/// as ; -/// preserves the prior state on failure (does not flip Active/Inactive). -/// -public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable -{ - private readonly ConcurrentDictionary> _cache - = new(StringComparer.Ordinal); - private readonly ILogger _logger; - private readonly SerilogLogger _scriptRoot; - private readonly TimeSpan _runTimeout; - private bool _disposed; - - /// Initializes a new instance of the Roslyn scripted alarm evaluator. - /// Logger for diagnostic messages (host diagnostics). - /// Root script logger; user ctx.Logger.* output flows through this to the Script-log page. - /// Optional timeout for script evaluation; defaults to 2 seconds. - public RoslynScriptedAlarmEvaluator( - ILogger logger, - ScriptRootLogger scriptRoot, - TimeSpan? runTimeout = null) - { - _logger = logger; - _scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger; - _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2); - } - - /// Evaluates a scripted alarm predicate against provided dependencies. - /// The alarm identifier for logging purposes. - /// The predicate expression to evaluate. - /// Variables available to the predicate expression. - /// Evaluation result with success flag and active state or failure reason. - public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies) - { - if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed"); - if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate"); - - ScriptEvaluator evaluator; - try - { - evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator.Compile); - } - catch (CompilationErrorException ex) - { - _logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId); - return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}"); - } - catch (ScriptSandboxViolationException ex) - { - _logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId); - return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId); - return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}"); - } - - var readCache = BuildReadCache(dependencies); - // Per-evaluation script logger: bind both ScriptId and AlarmId from the alarm id so the - // Script-log page can attribute each line to the owning scripted alarm. - var scriptLog = _scriptRoot - .ForContext(ScriptLoggerFactory.ScriptIdProperty, alarmId) - .ForContext(ScriptLoggerFactory.AlarmIdProperty, alarmId); - var context = new AlarmPredicateContext(readCache, scriptLog); - - try - { - using var cts = new CancellationTokenSource(_runTimeout); - var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult(); - return ScriptedAlarmEvalResult.Ok(active); - } - catch (OperationCanceledException) - { - return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId); - return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}"); - } - } - - private static IReadOnlyDictionary BuildReadCache( - IReadOnlyDictionary deps) - { - var nowUtc = DateTime.UtcNow; - var cache = new Dictionary(StringComparer.Ordinal); - foreach (var kv in deps) - { - cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc); - } - return cache; - } - - /// Disposes the evaluator and all cached script evaluators. - public void Dispose() - { - if (_disposed) return; - _disposed = true; - foreach (var ev in _cache.Values) - { - try { ev.Dispose(); } catch { /* best-effort */ } - } - _cache.Clear(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 1c0cd98f..f2a5109e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -114,14 +114,6 @@ if (hasDriver) sp.GetRequiredService())); builder.Services.AddSingleton(sp => sp.GetRequiredService()); - // F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on - // any Failure result, so a misbehaving script can't flip Active/Inactive spuriously. - builder.Services.AddSingleton(sp => - new RoslynScriptedAlarmEvaluator( - sp.GetRequiredService().CreateLogger(), - sp.GetRequiredService())); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - // Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never // races Akka startup. ScriptRootLogger wraps the composed pipeline (rolling scripts-*.log + // error mirror to the main log + script-logs DPS topic) for unambiguous DI resolution; Task 3 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs deleted file mode 100644 index 40d8d34b..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using ZB.MOM.WW.OtOpcUa.Commons.Engines; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; - -/// -/// Production-side backed by the -/// table in the central config DB. The actor's -/// 3-state enum projects into the table's two persisted dimensions: Acked + an -/// internal "_lastActiveState" recorded via a synthetic mapping (Inactive ⇒ Acked, -/// Active ⇒ Unacked, Acknowledged ⇒ Acked). ActiveState itself is deliberately NOT -/// persisted — re-derives from the evaluator on startup (Phase 7 decision #14). -/// -public sealed class EfAlarmActorStateStore : IAlarmActorStateStore -{ - private readonly IDbContextFactory _dbFactory; - private readonly ILogger _logger; - - /// Initializes a new instance of the EfAlarmActorStateStore. - /// The factory for creating database contexts. - /// The logger instance. - public EfAlarmActorStateStore( - IDbContextFactory dbFactory, - ILogger logger) - { - _dbFactory = dbFactory; - _logger = logger; - } - - /// Loads the alarm state snapshot from the database. - /// The identifier of the alarm. - /// The cancellation token. - /// The alarm state snapshot, or null if not found. - public async Task LoadAsync(string alarmId, CancellationToken ct) - { - using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var row = await db.ScriptedAlarmStates.AsNoTracking() - .FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct) - .ConfigureAwait(false); - if (row is null) return null; - - var state = MapAckedToActorState(row.AckedState); - return new AlarmActorStateSnapshot( - AlarmId: alarmId, - State: state, - LastTransitionUtc: row.UpdatedAtUtc, - LastAckUser: row.LastAckUser); - } - - /// Saves the alarm state snapshot to the database. - /// The alarm state snapshot to save. - /// The cancellation token. - public async Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) - { - using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); - var row = await db.ScriptedAlarmStates - .FirstOrDefaultAsync(r => r.ScriptedAlarmId == snapshot.AlarmId, ct) - .ConfigureAwait(false); - - var ackedState = MapActorStateToAcked(snapshot.State); - if (row is null) - { - db.ScriptedAlarmStates.Add(new ScriptedAlarmState - { - ScriptedAlarmId = snapshot.AlarmId, - EnabledState = "Enabled", - AckedState = ackedState, - ConfirmedState = "Confirmed", - ShelvingState = "Unshelved", - LastAckUser = snapshot.LastAckUser, - LastAckUtc = string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal) - ? snapshot.LastTransitionUtc - : null, - UpdatedAtUtc = snapshot.LastTransitionUtc, - CommentsJson = "[]", - }); - } - else - { - row.AckedState = ackedState; - row.LastAckUser = snapshot.LastAckUser ?? row.LastAckUser; - if (string.Equals(snapshot.State, "Acknowledged", StringComparison.Ordinal)) - row.LastAckUtc = snapshot.LastTransitionUtc; - row.UpdatedAtUtc = snapshot.LastTransitionUtc; - } - - try - { - await db.SaveChangesAsync(ct).ConfigureAwait(false); - } - catch (DbUpdateConcurrencyException ex) - { - // Two actors racing to save the same alarm is benign — the last writer wins on - // UpdatedAtUtc, and the next transition on either side will write again. Log - // + drop so a race doesn't crash the dispatcher. - _logger.LogDebug(ex, "EfAlarmActorStateStore: concurrency conflict for {AlarmId}; dropping save", - snapshot.AlarmId); - } - } - - private static string MapActorStateToAcked(string actorState) => actorState switch - { - "Active" => "Unacknowledged", - "Acknowledged" => "Acknowledged", - // Inactive maps to Acknowledged — when an alarm clears, nothing is left to ack. - _ => "Acknowledged", - }; - - private static string MapAckedToActorState(string ackedState) - { - // Only Active distinguishes from Acked — Inactive comes from a re-eval, not from - // the table. Persisted "Unacknowledged" implies the actor was last Active + - // un-acked; we restore it to Active so a restart doesn't drop pending operator work. - return string.Equals(ackedState, "Unacknowledged", StringComparison.Ordinal) - ? "Active" - : "Acknowledged"; - } -} 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 f4d56349..b0bdbe53 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs @@ -10,11 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; /// /// Production-side backed by the -/// table in the central config DB. Unlike the narrower -/// (which only persists AckedState for the actor's -/// 3-state enum), this store maps the full Part 9 — -/// Enabled / Acked / Confirmed / Shelving + the ack/confirm audit trail + operator -/// comments. +/// table in the central config DB. This store maps the +/// full Part 9 — Enabled / Acked / Confirmed / Shelving +/// + the ack/confirm audit trail + operator comments. /// /// /// @@ -34,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; /// /// serializes to/from /// via System.Text.Json. An empty list -/// round-trips as "[]" (matching the entity default + ). +/// round-trips as "[]" (matching the entity default). /// /// public sealed class EfAlarmConditionStateStore : IAlarmStateStore @@ -78,9 +76,9 @@ 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 + /// owning host actor (one actor owns the engine per equipment). 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. /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs deleted file mode 100644 index 50fcc2c0..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs +++ /dev/null @@ -1,254 +0,0 @@ -using Akka.Actor; -using Akka.Cluster.Tools.PublishSubscribe; -using Akka.Event; -using ZB.MOM.WW.OtOpcUa.Commons.Engines; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; -using ZB.MOM.WW.OtOpcUa.Commons.Observability; -using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; - -public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged } - -/// -/// One scripted alarm. Receives dependency value updates, runs the predicate via an -/// injected , and on transitions publishes both -/// an on the cluster alerts DPS topic and a -/// on script-logs. Manual -/// + still flow through the same state machine so the -/// legacy callers keep working. -/// -public sealed class ScriptedAlarmActor : ReceiveActor -{ - public const string AlertsTopic = "alerts"; - public const string ScriptLogsTopic = "script-logs"; - - public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc); - public sealed record ConditionMet(string Reason); - public sealed record AcknowledgeAlarm(string Actor); - public sealed record ConditionCleared; - public sealed record StateChanged(string AlarmId, ScriptedAlarmActorState State, DateTime AtUtc); - - public sealed record AlarmConfig( - string AlarmId, - string AlarmName, - string EquipmentPath, - int Severity, - string? Predicate); - - private readonly AlarmConfig _config; - private readonly IScriptedAlarmEvaluator _evaluator; - private readonly IAlarmActorStateStore _stateStore; - private readonly Func? _publisherFactory; - private readonly ILoggingAdapter _log = Context.GetLogger(); - private readonly Dictionary _dependencies = new(StringComparer.Ordinal); - - private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive; - private string? _lastAckUser; - - public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser); - - /// Creates a new Props for a ScriptedAlarmActor with the given configuration and optional dependencies. - /// The alarm configuration. - /// The alarm evaluator; defaults to null evaluator if not provided. - /// Optional factory for creating DPS publishers. - /// Optional state store for persistence; defaults to null store if not provided. - /// Akka Props for creating the actor. - public static Props Props( - AlarmConfig config, - IScriptedAlarmEvaluator? evaluator = null, - Func? publisherFactory = null, - IAlarmActorStateStore? stateStore = null) => - Akka.Actor.Props.Create(() => new ScriptedAlarmActor( - config, - evaluator ?? NullScriptedAlarmEvaluator.Instance, - publisherFactory, - stateStore ?? NullAlarmActorStateStore.Instance)); - - /// Legacy single-arg ctor kept for callers that only care about the state machine - /// (no engine evaluation, no DPS fan-out, no persistence). Equivalent to Props(new AlarmConfig(...)). - /// The alarm identifier, used as both alarm ID and name. - /// Akka Props for creating the actor with minimal configuration. - public static Props Props(string alarmId) => - Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null)); - - /// Initializes a new ScriptedAlarmActor with the given configuration and dependencies. - /// The alarm configuration. - /// The alarm predicate evaluator. - /// Optional factory for creating DPS publishers. - /// The state store for loading and saving alarm state. - public ScriptedAlarmActor( - AlarmConfig config, - IScriptedAlarmEvaluator evaluator, - Func? publisherFactory, - IAlarmActorStateStore stateStore) - { - _config = config; - _evaluator = evaluator; - _publisherFactory = publisherFactory; - _stateStore = stateStore; - - Receive(OnDependencyChanged); - Receive(_ => { if (_state == ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Active, user: "system"); }); - Receive(msg => { if (_state == ScriptedAlarmActorState.Active) Transition(ScriptedAlarmActorState.Acknowledged, user: msg.Actor); }); - Receive(_ => { if (_state != ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Inactive, user: "system"); }); - Receive(OnStateRestored); - } - - /// - protected override void PreStart() - { - // Load persisted state — when the store has a row, restore in-memory state before the - // first dependency-change arrives. Async I/O is piped back as StateRestored so we don't - // block the message-loop thread; until it arrives the actor stays at the default Inactive. - var self = Self; - _ = Task.Run(async () => - { - try - { - var snapshot = await _stateStore.LoadAsync(_config.AlarmId, CancellationToken.None) - .ConfigureAwait(false); - if (snapshot is null) return; - if (!Enum.TryParse(snapshot.State, ignoreCase: true, out var parsed)) - return; - self.Tell(new StateRestored(parsed, snapshot.LastAckUser)); - } - catch (Exception ex) - { - _log.Warning(ex, "ScriptedAlarm {Id}: state-store load failed; booting Inactive", - _config.AlarmId); - } - }); - } - - private void OnStateRestored(StateRestored msg) - { - // Active is re-derived from the evaluator at the next DependencyValueChanged — we still - // restore Active here so operators don't lose the in-flight transition if a restart races - // ahead of the next eval. The first evaluator tick will correct it if the condition cleared. - _state = msg.State; - _lastAckUser = msg.LastAckUser; - _log.Info("ScriptedAlarm {Id}: restored persisted state {State} (lastAck={User})", - _config.AlarmId, _state, _lastAckUser ?? "(none)"); - } - - private void OnDependencyChanged(DependencyValueChanged msg) - { - _dependencies[msg.TagId] = msg.Value; - - if (string.IsNullOrEmpty(_config.Predicate)) return; - - ScriptedAlarmEvalResult result; - try - { - result = _evaluator.Evaluate(_config.AlarmId, _config.Predicate, _dependencies); - } - catch (Exception ex) - { - _log.Warning(ex, "ScriptedAlarm {Id}: evaluator threw", _config.AlarmId); - PublishLog("Error", $"evaluator threw: {ex.Message}"); - return; - } - - if (!result.Success) - { - PublishLog("Warning", result.Reason ?? "evaluator failure"); - return; - } - - // Active condition wins regardless of ack state — re-firing is suppressed because - // _state already == Active. Cleared moves Active OR Acknowledged → Inactive. - if (result.Active && _state == ScriptedAlarmActorState.Inactive) - { - Transition(ScriptedAlarmActorState.Active, user: "system"); - } - else if (!result.Active && _state != ScriptedAlarmActorState.Inactive) - { - Transition(ScriptedAlarmActorState.Inactive, user: "system"); - } - } - - private void Transition(ScriptedAlarmActorState next, string user) - { - var prev = _state; - _state = next; - if (next == ScriptedAlarmActorState.Acknowledged) _lastAckUser = user; - _log.Info("ScriptedAlarm {Id}: {From} → {To}", _config.AlarmId, prev, next); - - var nowUtc = DateTime.UtcNow; - Context.Parent.Tell(new StateChanged(_config.AlarmId, next, nowUtc)); - PersistStateAsync(nowUtc); - - var kind = next switch - { - ScriptedAlarmActorState.Active => "Activated", - ScriptedAlarmActorState.Acknowledged => "Acknowledged", - ScriptedAlarmActorState.Inactive => "Cleared", - _ => next.ToString(), - }; - - OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1, - new KeyValuePair("state", kind.ToLowerInvariant())); - - var evt = new AlarmTransitionEvent( - AlarmId: _config.AlarmId, - EquipmentPath: _config.EquipmentPath, - AlarmName: _config.AlarmName, - TransitionKind: kind, - Severity: _config.Severity, - Message: $"{_config.AlarmName} {kind}", - User: user, - TimestampUtc: nowUtc); - - PublishOrFallback(AlertsTopic, evt); - PublishLog("Information", $"{_config.AlarmName} {kind} (by {user})"); - } - - private void PublishLog(string level, string message) - { - var entry = new ScriptLogEntry( - ScriptId: _config.AlarmId, - Level: level, - Message: message, - TimestampUtc: DateTime.UtcNow, - VirtualTagId: null, - AlarmId: _config.AlarmId, - EquipmentId: null); - PublishOrFallback(ScriptLogsTopic, entry); - } - - private void PublishOrFallback(string topic, object payload) - { - if (_publisherFactory is not null) - { - _publisherFactory().Publish(topic, payload); - return; - } - DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(topic, payload)); - } - - private void PersistStateAsync(DateTime nowUtc) - { - var snapshot = new AlarmActorStateSnapshot( - AlarmId: _config.AlarmId, - State: _state.ToString(), - LastTransitionUtc: nowUtc, - LastAckUser: _lastAckUser); - - // Fire-and-forget. Save failures get logged but don't block the message loop — - // the worst case is a restart loses one transition, which then re-derives from - // the evaluator's next tick anyway. - _ = Task.Run(async () => - { - try - { - await _stateStore.SaveAsync(snapshot, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _log.Warning(ex, "ScriptedAlarm {Id}: state-store save failed", _config.AlarmId); - } - }); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs deleted file mode 100644 index cce8a271..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Microsoft.Extensions.Logging.Abstractions; -using Serilog; -using Serilog.Events; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; -using ZB.MOM.WW.OtOpcUa.Core.Scripting; -using ZB.MOM.WW.OtOpcUa.Host.Engines; - -namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; - -/// -/// F9b — verifies compiles alarm predicates, -/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the -/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the -/// AlarmPredicateContext throws on writes — predicates must stay pure). -/// -public sealed class RoslynScriptedAlarmEvaluatorTests -{ - /// Captures published records for assertion. - private sealed class FakePublisher : IScriptLogPublisher - { - /// Gets the entries published so far. - public List Published { get; } = []; - - /// - public void Publish(ScriptLogEntry entry) => Published.Add(entry); - } - - /// Builds a no-op for tests that don't assert on logging. - private static ScriptRootLogger NoOpScriptRoot() => - new(new LoggerConfiguration().CreateLogger()); - - /// Verifies evaluation of predicate returning true reports Active. - [Fact] - public void Evaluate_predicate_returning_true_reports_Active() - { - using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot()); - - var result = sut.Evaluate( - alarmId: "alarm-hi", - predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;", - dependencies: new Dictionary { ["temp"] = 150 }); - - result.Success.ShouldBeTrue(result.Reason); - result.Active.ShouldBeTrue(); - } - - /// Verifies evaluation of predicate returning false reports Inactive. - [Fact] - public void Evaluate_predicate_returning_false_reports_Inactive() - { - using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot()); - - var result = sut.Evaluate( - alarmId: "alarm-hi", - predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;", - dependencies: new Dictionary { ["temp"] = 50 }); - - result.Success.ShouldBeTrue(result.Reason); - result.Active.ShouldBeFalse(); - } - - /// Verifies compiled predicates are cached across calls. - [Fact] - public void Evaluate_caches_compiled_predicate_across_calls() - { - using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot()); - const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;"; - - var first = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = true }); - var second = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = false }); - - first.Active.ShouldBeTrue(); - second.Active.ShouldBeFalse(); - } - - /// Verifies compile errors return Failure. - [Fact] - public void Evaluate_compile_error_returns_Failure() - { - using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot()); - - var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary()); - - result.Success.ShouldBeFalse(); - result.Reason!.ShouldContain("compile"); - } - - /// Verifies predicate writing virtual tag returns Failure. - [Fact] - public void Evaluate_predicate_writing_virtual_tag_returns_Failure() - { - using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot()); - - // AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure. - var result = sut.Evaluate( - alarmId: "alarm-bad-write", - predicate: "ctx.SetVirtualTag(\"x\", 1); return true;", - dependencies: new Dictionary()); - - result.Success.ShouldBeFalse(); - result.Reason!.ShouldContain("threw"); - } - - /// Verifies empty predicate returns Failure. - [Fact] - public void Evaluate_empty_predicate_returns_Failure() - { - using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot()); - - sut.Evaluate("alarm-empty", "", new Dictionary()).Success.ShouldBeFalse(); - } - - /// Verifies evaluation after dispose returns Failure. - [Fact] - public void Evaluate_after_dispose_returns_Failure() - { - var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot()); - sut.Dispose(); - - var result = sut.Evaluate("alarm", "return true;", new Dictionary()); - - result.Success.ShouldBeFalse(); - result.Reason!.ShouldContain("disposed"); - } - - /// - /// A predicate's ctx.Logger.Warning(...) call flows through the injected root script - /// logger and out the , producing one - /// carrying the message, the bound AlarmId, and the - /// Warning level. - /// - [Fact] - public void Script_logger_call_publishes_entry_with_bound_alarm_identity() - { - var publisher = new FakePublisher(); - var root = new LoggerConfiguration() - .MinimumLevel.Verbose() - .WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information)) - .CreateLogger(); - using var sut = new RoslynScriptedAlarmEvaluator( - NullLogger.Instance, new ScriptRootLogger(root)); - - var result = sut.Evaluate( - alarmId: "alarm-log", - predicate: "ctx.Logger.Warning(\"alarm log\"); return true;", - dependencies: new Dictionary()); - - result.Success.ShouldBeTrue(result.Reason); - result.Active.ShouldBeTrue(); - publisher.Published.Count.ShouldBe(1); - var entry = publisher.Published[0]; - entry.Message.ShouldBe("alarm log"); - entry.AlarmId.ShouldBe("alarm-log"); - entry.ScriptId.ShouldBe("alarm-log"); - entry.Level.ShouldBe("Warning"); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs deleted file mode 100644 index a7393ed5..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmActorTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Collections.Concurrent; -using Akka.Actor; -using Akka.TestKit; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Engines; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; -using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; -using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; - -public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase -{ - /// Verifies that full state cycle publishes StateChanged messages to parent at each transition. - [Fact] - public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition() - { - var parent = CreateTestProbe(); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1")); - - actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold")); - var t1 = parent.ExpectMsg(); - t1.State.ShouldBe(ScriptedAlarmActorState.Active); - - actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("joe")); - var t2 = parent.ExpectMsg(); - t2.State.ShouldBe(ScriptedAlarmActorState.Acknowledged); - - actor.Tell(new ScriptedAlarmActor.ConditionCleared()); - var t3 = parent.ExpectMsg(); - t3.State.ShouldBe(ScriptedAlarmActorState.Inactive); - } - - /// Verifies that duplicate ConditionMet messages in Active state are ignored. - [Fact] - public void Duplicate_ConditionMet_in_Active_is_ignored() - { - var parent = CreateTestProbe(); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1")); - - actor.Tell(new ScriptedAlarmActor.ConditionMet("first")); - parent.ExpectMsg(); - - actor.Tell(new ScriptedAlarmActor.ConditionMet("second")); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - } - - /// Verifies that active transition publishes AlarmTransitionEvent to the alerts topic. - [Fact] - public void Engine_active_transition_publishes_AlarmTransitionEvent_to_alerts_topic() - { - var capture = new CapturingPublisher(); - var parent = CreateTestProbe(); - var config = new ScriptedAlarmActor.AlarmConfig( - AlarmId: "alarm-7", - AlarmName: "High Temp", - EquipmentPath: "/site-1/line-A/oven", - Severity: 800, - Predicate: "temp > 80"); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props( - config, - evaluator: new ThresholdEvaluator(80), - publisherFactory: () => new DPSPublisher(capture.Publish))); - - actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow)); - parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Active); - - AwaitAssert(() => - { - var transitionEvt = capture.Payloads.OfType().SingleOrDefault(); - transitionEvt.ShouldNotBeNull(); - transitionEvt.AlarmId.ShouldBe("alarm-7"); - transitionEvt.AlarmName.ShouldBe("High Temp"); - transitionEvt.EquipmentPath.ShouldBe("/site-1/line-A/oven"); - transitionEvt.Severity.ShouldBe(800); - transitionEvt.TransitionKind.ShouldBe("Activated"); - transitionEvt.User.ShouldBe("system"); - - var log = capture.Payloads.OfType().SingleOrDefault(); - log.ShouldNotBeNull(); - log.AlarmId.ShouldBe("alarm-7"); - }, duration: TimeSpan.FromSeconds(1)); - } - - /// Verifies that clear transition publishes Cleared event. - [Fact] - public void Engine_clear_transition_publishes_Cleared_event() - { - var capture = new CapturingPublisher(); - var parent = CreateTestProbe(); - var config = new ScriptedAlarmActor.AlarmConfig("alarm-7", "High Temp", "/p", 500, "temp > 80"); - var evaluator = new ThresholdEvaluator(80); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props( - config, evaluator, - publisherFactory: () => new DPSPublisher(capture.Publish))); - - actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow)); - parent.ExpectMsg(); - - actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 70, DateTime.UtcNow)); - parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Inactive); - - AwaitAssert(() => - { - var kinds = capture.Payloads.OfType().Select(e => e.TransitionKind).ToList(); - kinds.ShouldContain("Activated"); - kinds.ShouldContain("Cleared"); - }, duration: TimeSpan.FromSeconds(1)); - } - - /// Verifies that manual acknowledge emits Acknowledged transition with the user. - [Fact] - public void Manual_acknowledge_emits_Acknowledged_transition_with_user() - { - var capture = new CapturingPublisher(); - var parent = CreateTestProbe(); - var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump Fail", "/eq", 700, Predicate: null); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props( - config, evaluator: null, - publisherFactory: () => new DPSPublisher(capture.Publish))); - - actor.Tell(new ScriptedAlarmActor.ConditionMet("driver-fault")); - parent.ExpectMsg(); - - actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane")); - parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Acknowledged); - - AwaitAssert(() => - { - var ackEvt = capture.Payloads.OfType() - .SingleOrDefault(e => e.TransitionKind == "Acknowledged"); - ackEvt.ShouldNotBeNull(); - ackEvt.User.ShouldBe("operator-jane"); - }, duration: TimeSpan.FromSeconds(1)); - } - - /// A threshold-based alarm evaluator for testing. - private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator - { - private readonly double _threshold; - - /// Initializes a new instance of the ThresholdEvaluator class. - /// The threshold value to compare against. - public ThresholdEvaluator(double threshold) { _threshold = threshold; } - - /// - public ScriptedAlarmEvalResult Evaluate(string id, string predicate, IReadOnlyDictionary deps) - { - if (!deps.TryGetValue("temp", out var raw) || raw is null) - return ScriptedAlarmEvalResult.Failure("missing temp"); - return ScriptedAlarmEvalResult.Ok(Convert.ToDouble(raw) > _threshold); - } - } - - /// A test publisher that captures published messages. - private sealed class CapturingPublisher - { - /// Gets the topics that messages were published to. - public ConcurrentBag Topics { get; } = new(); - - /// Gets the payloads that were published. - public ConcurrentBag Payloads { get; } = new(); - - /// Publishes a message to the specified topic. - /// The topic name. - /// The message payload. - public void Publish(string topic, object payload) - { - Topics.Add(topic); - Payloads.Add(payload); - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmStatePersistenceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmStatePersistenceTests.cs deleted file mode 100644 index a8c3c317..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmStatePersistenceTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Collections.Concurrent; -using Akka.Actor; -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Engines; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; - -public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase -{ - /// Verifies that alarm state transitions write to the state store with the correct lastAckUser value. - [Fact] - public async Task Transition_writes_to_state_store_with_lastAckUser() - { - var store = new RecordingStateStore(); - var parent = CreateTestProbe(); - var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump", "/eq", 700, Predicate: null); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store)); - - actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold")); - parent.ExpectMsg(); - AwaitAssert(() => - { - store.Snapshots.Last().State.ShouldBe("Active"); - store.Snapshots.Last().LastAckUser.ShouldBeNull(); - }, duration: TimeSpan.FromSeconds(2)); - - actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane")); - parent.ExpectMsg(); - AwaitAssert(() => - { - var ackedSnap = store.Snapshots.Last(s => s.State == "Acknowledged"); - ackedSnap.LastAckUser.ShouldBe("operator-jane"); - }, duration: TimeSpan.FromSeconds(2)); - } - - /// Verifies that actor restart restores persisted state so pending acknowledgment is not dropped. - [Fact] - public async Task PreStart_restores_persisted_state_so_restart_does_not_drop_pending_ack() - { - var store = new RecordingStateStore(); - await store.SaveAsync(new AlarmActorStateSnapshot( - AlarmId: "a-1", - State: "Active", - LastTransitionUtc: DateTime.UtcNow.AddMinutes(-5), - LastAckUser: null), CancellationToken.None); - - var parent = CreateTestProbe(); - var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump", "/eq", 700, Predicate: null); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store)); - - // After PreStart's async load, the actor should be in Active — duplicate ConditionMet - // is then ignored because the existing Active-state check. - AwaitAssert(() => - { - actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-bob")); - parent.ExpectMsg(TimeSpan.FromMilliseconds(500)) - .State.ShouldBe(ScriptedAlarmActorState.Acknowledged); - }, duration: TimeSpan.FromSeconds(3)); - } - - /// Verifies that alarm boots to inactive state when no persisted state exists. - [Fact] - public async Task PreStart_with_no_persisted_state_boots_inactive() - { - var store = new RecordingStateStore(); - var parent = CreateTestProbe(); - var config = new ScriptedAlarmActor.AlarmConfig("never-seen", "X", "/eq", 500, Predicate: null); - var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store)); - - // Empty store ⇒ actor sits Inactive; AcknowledgeAlarm is ignored from Inactive so no - // StateChanged should arrive. - await Task.Delay(200); - actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("anyone")); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// Verifies that EF-based alarm actor state store correctly persists and restores state through the config database. - [Fact] - public async Task EfAlarmActorStateStore_round_trip_persists_via_ConfigDb() - { - var db = NewInMemoryDbFactory(); - var ef = new EfAlarmActorStateStore(db, NullLogger.Instance); - - await ef.SaveAsync(new AlarmActorStateSnapshot( - AlarmId: "alarm-7", - State: "Active", - LastTransitionUtc: DateTime.UtcNow, - LastAckUser: null), CancellationToken.None); - - using (var ctx = db.CreateDbContext()) - { - var row = ctx.ScriptedAlarmStates.Single(r => r.ScriptedAlarmId == "alarm-7"); - row.AckedState.ShouldBe("Unacknowledged"); - } - - // Acknowledge — same alarmId, transitions to Acknowledged. - await ef.SaveAsync(new AlarmActorStateSnapshot( - AlarmId: "alarm-7", - State: "Acknowledged", - LastTransitionUtc: DateTime.UtcNow, - LastAckUser: "jane"), CancellationToken.None); - - var loaded = await ef.LoadAsync("alarm-7", CancellationToken.None); - loaded.ShouldNotBeNull(); - loaded.State.ShouldBe("Acknowledged"); - loaded.LastAckUser.ShouldBe("jane"); - - using (var ctx = db.CreateDbContext()) - { - ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-7").ShouldBe(1); - ctx.ScriptedAlarmStates.Single(r => r.ScriptedAlarmId == "alarm-7").LastAckUser.ShouldBe("jane"); - } - } - - /// Verifies that loading an alarm state for a missing ID returns null. - [Fact] - public async Task EfAlarmActorStateStore_load_for_missing_id_returns_null() - { - var db = NewInMemoryDbFactory(); - var ef = new EfAlarmActorStateStore(db, NullLogger.Instance); - - var loaded = await ef.LoadAsync("never-saved", CancellationToken.None); - loaded.ShouldBeNull(); - } - - private sealed class RecordingStateStore : IAlarmActorStateStore - { - private readonly ConcurrentDictionary _byId = new(StringComparer.Ordinal); - private readonly ConcurrentQueue _saves = new(); - - /// Gets all saved alarm state snapshots in order. - public List Snapshots => _saves.ToList(); - - /// Loads the alarm state snapshot for the specified alarm ID. - /// The alarm ID. - /// The cancellation token. - /// The alarm state snapshot if found, null otherwise. - public Task LoadAsync(string alarmId, CancellationToken ct) - => Task.FromResult(_byId.TryGetValue(alarmId, out var v) ? v : null); - - /// Saves the alarm state snapshot. - /// The alarm state snapshot to save. - /// The cancellation token. - /// A completed task. - public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) - { - _byId[snapshot.AlarmId] = snapshot; - _saves.Enqueue(snapshot); - return Task.CompletedTask; - } - } -}