refactor(scripted-alarms): retire orphaned ScriptedAlarmActor + F9b evaluator (T11)
This commit is contained in:
@@ -1,54 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persistence seam for <c>ScriptedAlarmActor</c>'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
|
|
||||||
/// (<see cref="Configuration.Entities.ScriptedAlarmState"/>'s Comments/Confirmed/Shelving)
|
|
||||||
/// stays in the production engine binding — this seam is the small surface the actor
|
|
||||||
/// consumes directly.
|
|
||||||
/// </summary>
|
|
||||||
public interface IAlarmActorStateStore
|
|
||||||
{
|
|
||||||
/// <summary>Loads the persisted state snapshot for an alarm actor.</summary>
|
|
||||||
/// <param name="alarmId">The alarm identifier.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
/// <returns>The alarm state snapshot if found; null if the alarm has no persisted state.</returns>
|
|
||||||
Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct);
|
|
||||||
/// <summary>Saves the alarm actor state snapshot.</summary>
|
|
||||||
/// <param name="snapshot">The state snapshot to persist.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Persisted slice of <c>ScriptedAlarmActor</c>'s state. Active is NOT persisted —
|
|
||||||
/// it re-derives from the evaluator on startup per Phase 7 decision #14. <c>State</c> here
|
|
||||||
/// distinguishes Acknowledged vs not-yet-acknowledged for cases where the actor came up
|
|
||||||
/// Active and operator interaction had already happened.</summary>
|
|
||||||
/// <param name="AlarmId">Matches <c>ScriptedAlarm.ScriptedAlarmId</c>.</param>
|
|
||||||
/// <param name="State">Inactive / Active / Acknowledged — the actor's 3-state enum, projected to string.</param>
|
|
||||||
/// <param name="LastTransitionUtc">When the actor last transitioned.</param>
|
|
||||||
/// <param name="LastAckUser">Who acknowledged most recently. Null when never acked.</param>
|
|
||||||
public sealed record AlarmActorStateSnapshot(
|
|
||||||
string AlarmId,
|
|
||||||
string State,
|
|
||||||
DateTime LastTransitionUtc,
|
|
||||||
string? LastAckUser);
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
|
|
||||||
{
|
|
||||||
public static readonly NullAlarmActorStateStore Instance = new();
|
|
||||||
private NullAlarmActorStateStore() { }
|
|
||||||
/// <summary>Always returns null, indicating no persisted state.</summary>
|
|
||||||
/// <param name="alarmId">The alarm identifier (unused).</param>
|
|
||||||
/// <param name="ct">Cancellation token (unused).</param>
|
|
||||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
|
|
||||||
Task.FromResult<AlarmActorStateSnapshot?>(null);
|
|
||||||
/// <summary>Completes immediately without persisting anything.</summary>
|
|
||||||
/// <param name="snapshot">The state snapshot (ignored).</param>
|
|
||||||
/// <param name="ct">Cancellation token (unused).</param>
|
|
||||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
|
||||||
Task.CompletedTask;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Abstraction over the scripted-alarm predicate engine. Production binds this to a
|
|
||||||
/// wrapper around <c>ScriptedAlarmEngine</c> from <c>Core.ScriptedAlarms</c>; default
|
|
||||||
/// binding is <see cref="NullScriptedAlarmEvaluator"/> which keeps the alarm in its
|
|
||||||
/// current state (so an unconfigured node never spuriously alarms).
|
|
||||||
/// </summary>
|
|
||||||
public interface IScriptedAlarmEvaluator
|
|
||||||
{
|
|
||||||
/// <summary>Evaluates an alarm predicate against the provided dependencies.</summary>
|
|
||||||
/// <param name="alarmId">The unique identifier of the alarm being evaluated.</param>
|
|
||||||
/// <param name="predicate">The predicate expression to evaluate.</param>
|
|
||||||
/// <param name="dependencies">Read-only dictionary of variable names to values for predicate evaluation.</param>
|
|
||||||
/// <returns>Result containing success flag, alarm active state, and optional failure reason.</returns>
|
|
||||||
ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Result of one alarm-predicate evaluation. <c>Active</c> is only meaningful when
|
|
||||||
/// <c>Success</c> is true; on failure the caller should keep the prior state and log Reason.</summary>
|
|
||||||
public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason)
|
|
||||||
{
|
|
||||||
/// <summary>Creates a successful alarm evaluation result with the given active state.</summary>
|
|
||||||
/// <param name="active">Whether the alarm condition is active.</param>
|
|
||||||
/// <returns>A successful evaluation result.</returns>
|
|
||||||
public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null);
|
|
||||||
|
|
||||||
/// <summary>Creates a failed alarm evaluation result with the given reason.</summary>
|
|
||||||
/// <param name="reason">Description of the evaluation failure cause.</param>
|
|
||||||
/// <returns>A failed evaluation result.</returns>
|
|
||||||
public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Default that always returns <c>Active = false, Success = true</c>. Safe no-op:
|
|
||||||
/// no alarm fires when no real engine is bound.</summary>
|
|
||||||
public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator
|
|
||||||
{
|
|
||||||
public static readonly NullScriptedAlarmEvaluator Instance = new();
|
|
||||||
private NullScriptedAlarmEvaluator() { }
|
|
||||||
/// <summary>Returns an inactive alarm result for every evaluation (safe no-op behavior).</summary>
|
|
||||||
/// <param name="alarmId">The alarm identifier (ignored).</param>
|
|
||||||
/// <param name="predicate">The predicate expression (ignored).</param>
|
|
||||||
/// <param name="dependencies">The variable dependencies (ignored).</param>
|
|
||||||
/// <returns>Always returns an inactive alarm result.</returns>
|
|
||||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
|
||||||
=> ScriptedAlarmEvalResult.Ok(active: false);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// F9b — production <see cref="IScriptedAlarmEvaluator"/> binding. Compiles each unique
|
|
||||||
/// predicate once via <see cref="ScriptEvaluator{TContext, TResult}"/> against
|
|
||||||
/// <see cref="AlarmPredicateContext"/> and caches the resulting evaluator. Predicates are
|
|
||||||
/// pure functions returning <c>bool</c>: <see cref="AlarmPredicateContext.SetVirtualTag"/>
|
|
||||||
/// 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 <see cref="ScriptedAlarmEvalResult.Failure"/>; <see cref="ScriptedAlarmActor"/>
|
|
||||||
/// preserves the prior state on failure (does not flip Active/Inactive).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
|
|
||||||
{
|
|
||||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
|
|
||||||
= new(StringComparer.Ordinal);
|
|
||||||
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
|
|
||||||
private readonly SerilogLogger _scriptRoot;
|
|
||||||
private readonly TimeSpan _runTimeout;
|
|
||||||
private bool _disposed;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the Roslyn scripted alarm evaluator.</summary>
|
|
||||||
/// <param name="logger">Logger for diagnostic messages (host diagnostics).</param>
|
|
||||||
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
|
||||||
/// <param name="runTimeout">Optional timeout for script evaluation; defaults to 2 seconds.</param>
|
|
||||||
public RoslynScriptedAlarmEvaluator(
|
|
||||||
ILogger<RoslynScriptedAlarmEvaluator> logger,
|
|
||||||
ScriptRootLogger scriptRoot,
|
|
||||||
TimeSpan? runTimeout = null)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
|
||||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Evaluates a scripted alarm predicate against provided dependencies.</summary>
|
|
||||||
/// <param name="alarmId">The alarm identifier for logging purposes.</param>
|
|
||||||
/// <param name="predicate">The predicate expression to evaluate.</param>
|
|
||||||
/// <param name="dependencies">Variables available to the predicate expression.</param>
|
|
||||||
/// <returns>Evaluation result with success flag and active state or failure reason.</returns>
|
|
||||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
|
||||||
{
|
|
||||||
if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
|
|
||||||
if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
|
|
||||||
|
|
||||||
ScriptEvaluator<AlarmPredicateContext, bool> evaluator;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator<AlarmPredicateContext, bool>.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<string, DataValueSnapshot> BuildReadCache(
|
|
||||||
IReadOnlyDictionary<string, object?> deps)
|
|
||||||
{
|
|
||||||
var nowUtc = DateTime.UtcNow;
|
|
||||||
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
||||||
foreach (var kv in deps)
|
|
||||||
{
|
|
||||||
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
|
||||||
}
|
|
||||||
return cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Disposes the evaluator and all cached script evaluators.</summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
_disposed = true;
|
|
||||||
foreach (var ev in _cache.Values)
|
|
||||||
{
|
|
||||||
try { ev.Dispose(); } catch { /* best-effort */ }
|
|
||||||
}
|
|
||||||
_cache.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -114,14 +114,6 @@ if (hasDriver)
|
|||||||
sp.GetRequiredService<ScriptRootLogger>()));
|
sp.GetRequiredService<ScriptRootLogger>()));
|
||||||
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||||
|
|
||||||
// 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<RoslynScriptedAlarmEvaluator>(sp =>
|
|
||||||
new RoslynScriptedAlarmEvaluator(
|
|
||||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>(),
|
|
||||||
sp.GetRequiredService<ScriptRootLogger>()));
|
|
||||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
|
||||||
|
|
||||||
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
|
// 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 +
|
// 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
|
// error mirror to the main log + script-logs DPS topic) for unambiguous DI resolution; Task 3
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Production-side <see cref="IAlarmActorStateStore"/> backed by the
|
|
||||||
/// <see cref="ScriptedAlarmState"/> 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).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
|
|
||||||
{
|
|
||||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
|
||||||
private readonly ILogger<EfAlarmActorStateStore> _logger;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the EfAlarmActorStateStore.</summary>
|
|
||||||
/// <param name="dbFactory">The factory for creating database contexts.</param>
|
|
||||||
/// <param name="logger">The logger instance.</param>
|
|
||||||
public EfAlarmActorStateStore(
|
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
|
||||||
ILogger<EfAlarmActorStateStore> logger)
|
|
||||||
{
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Loads the alarm state snapshot from the database.</summary>
|
|
||||||
/// <param name="alarmId">The identifier of the alarm.</param>
|
|
||||||
/// <param name="ct">The cancellation token.</param>
|
|
||||||
/// <returns>The alarm state snapshot, or null if not found.</returns>
|
|
||||||
public async Task<AlarmActorStateSnapshot?> 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Saves the alarm state snapshot to the database.</summary>
|
|
||||||
/// <param name="snapshot">The alarm state snapshot to save.</param>
|
|
||||||
/// <param name="ct">The cancellation token.</param>
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Production-side <see cref="IAlarmStateStore"/> backed by the
|
/// Production-side <see cref="IAlarmStateStore"/> backed by the
|
||||||
/// <see cref="ScriptedAlarmState"/> table in the central config DB. Unlike the narrower
|
/// <see cref="ScriptedAlarmState"/> table in the central config DB. This store maps the
|
||||||
/// <see cref="EfAlarmActorStateStore"/> (which only persists AckedState for the actor's
|
/// full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed / Shelving
|
||||||
/// 3-state enum), this store maps the full Part 9 <see cref="AlarmConditionState"/> —
|
/// + the ack/confirm audit trail + operator comments.
|
||||||
/// Enabled / Acked / Confirmed / Shelving + the ack/confirm audit trail + operator
|
|
||||||
/// comments.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -34,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="AlarmConditionState.Comments"/> serializes to/from
|
/// <see cref="AlarmConditionState.Comments"/> serializes to/from
|
||||||
/// <see cref="ScriptedAlarmState.CommentsJson"/> via System.Text.Json. An empty list
|
/// <see cref="ScriptedAlarmState.CommentsJson"/> via System.Text.Json. An empty list
|
||||||
/// round-trips as <c>"[]"</c> (matching the entity default + <see cref="EfAlarmActorStateStore"/>).
|
/// round-trips as <c>"[]"</c> (matching the entity default).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
||||||
@@ -78,9 +76,9 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>Concurrency assumption</b>: saves for a given <c>alarmId</c> are serialized by the
|
/// <b>Concurrency assumption</b>: saves for a given <c>alarmId</c> are serialized by the
|
||||||
/// owning host actor (one actor owns the engine per equipment), mirroring
|
/// owning host actor (one actor owns the engine per equipment). The check-then-insert
|
||||||
/// <c>EfAlarmActorStateStore</c>. The check-then-insert pattern is therefore safe under
|
/// pattern is therefore safe under that guarantee — two concurrent inserts for the same
|
||||||
/// that guarantee — two concurrent inserts for the same alarm cannot occur in the live
|
/// alarm cannot occur in the live
|
||||||
/// runtime. The <see cref="DbUpdateConcurrencyException"/> catch handles the edge case of a
|
/// runtime. The <see cref="DbUpdateConcurrencyException"/> catch handles the edge case of a
|
||||||
/// racing concurrent restart during crash recovery.
|
/// racing concurrent restart during crash recovery.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One scripted alarm. Receives dependency value updates, runs the predicate via an
|
|
||||||
/// injected <see cref="IScriptedAlarmEvaluator"/>, and on transitions publishes both
|
|
||||||
/// an <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> DPS topic and a
|
|
||||||
/// <see cref="ScriptLogEntry"/> on <c>script-logs</c>. Manual <see cref="AcknowledgeAlarm"/>
|
|
||||||
/// + <see cref="ConditionCleared"/> still flow through the same state machine so the
|
|
||||||
/// legacy callers keep working.
|
|
||||||
/// </summary>
|
|
||||||
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<DPSPublisher>? _publisherFactory;
|
|
||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
|
||||||
private readonly Dictionary<string, object?> _dependencies = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
private ScriptedAlarmActorState _state = ScriptedAlarmActorState.Inactive;
|
|
||||||
private string? _lastAckUser;
|
|
||||||
|
|
||||||
public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser);
|
|
||||||
|
|
||||||
/// <summary>Creates a new Props for a ScriptedAlarmActor with the given configuration and optional dependencies.</summary>
|
|
||||||
/// <param name="config">The alarm configuration.</param>
|
|
||||||
/// <param name="evaluator">The alarm evaluator; defaults to null evaluator if not provided.</param>
|
|
||||||
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
|
|
||||||
/// <param name="stateStore">Optional state store for persistence; defaults to null store if not provided.</param>
|
|
||||||
/// <returns>Akka Props for creating the actor.</returns>
|
|
||||||
public static Props Props(
|
|
||||||
AlarmConfig config,
|
|
||||||
IScriptedAlarmEvaluator? evaluator = null,
|
|
||||||
Func<DPSPublisher>? publisherFactory = null,
|
|
||||||
IAlarmActorStateStore? stateStore = null) =>
|
|
||||||
Akka.Actor.Props.Create(() => new ScriptedAlarmActor(
|
|
||||||
config,
|
|
||||||
evaluator ?? NullScriptedAlarmEvaluator.Instance,
|
|
||||||
publisherFactory,
|
|
||||||
stateStore ?? NullAlarmActorStateStore.Instance));
|
|
||||||
|
|
||||||
/// <summary>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 <c>Props(new AlarmConfig(...))</c>.</summary>
|
|
||||||
/// <param name="alarmId">The alarm identifier, used as both alarm ID and name.</param>
|
|
||||||
/// <returns>Akka Props for creating the actor with minimal configuration.</returns>
|
|
||||||
public static Props Props(string alarmId) =>
|
|
||||||
Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));
|
|
||||||
|
|
||||||
/// <summary>Initializes a new ScriptedAlarmActor with the given configuration and dependencies.</summary>
|
|
||||||
/// <param name="config">The alarm configuration.</param>
|
|
||||||
/// <param name="evaluator">The alarm predicate evaluator.</param>
|
|
||||||
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
|
|
||||||
/// <param name="stateStore">The state store for loading and saving alarm state.</param>
|
|
||||||
public ScriptedAlarmActor(
|
|
||||||
AlarmConfig config,
|
|
||||||
IScriptedAlarmEvaluator evaluator,
|
|
||||||
Func<DPSPublisher>? publisherFactory,
|
|
||||||
IAlarmActorStateStore stateStore)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
_evaluator = evaluator;
|
|
||||||
_publisherFactory = publisherFactory;
|
|
||||||
_stateStore = stateStore;
|
|
||||||
|
|
||||||
Receive<DependencyValueChanged>(OnDependencyChanged);
|
|
||||||
Receive<ConditionMet>(_ => { if (_state == ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Active, user: "system"); });
|
|
||||||
Receive<AcknowledgeAlarm>(msg => { if (_state == ScriptedAlarmActorState.Active) Transition(ScriptedAlarmActorState.Acknowledged, user: msg.Actor); });
|
|
||||||
Receive<ConditionCleared>(_ => { if (_state != ScriptedAlarmActorState.Inactive) Transition(ScriptedAlarmActorState.Inactive, user: "system"); });
|
|
||||||
Receive<StateRestored>(OnStateRestored);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
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<ScriptedAlarmActorState>(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<string, object?>("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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-159
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> 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).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class RoslynScriptedAlarmEvaluatorTests
|
|
||||||
{
|
|
||||||
/// <summary>Captures published <see cref="ScriptLogEntry"/> records for assertion.</summary>
|
|
||||||
private sealed class FakePublisher : IScriptLogPublisher
|
|
||||||
{
|
|
||||||
/// <summary>Gets the entries published so far.</summary>
|
|
||||||
public List<ScriptLogEntry> Published { get; } = [];
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on logging.</summary>
|
|
||||||
private static ScriptRootLogger NoOpScriptRoot() =>
|
|
||||||
new(new LoggerConfiguration().CreateLogger());
|
|
||||||
|
|
||||||
/// <summary>Verifies evaluation of predicate returning true reports Active.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Evaluate_predicate_returning_true_reports_Active()
|
|
||||||
{
|
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
|
||||||
alarmId: "alarm-hi",
|
|
||||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
|
||||||
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
|
|
||||||
|
|
||||||
result.Success.ShouldBeTrue(result.Reason);
|
|
||||||
result.Active.ShouldBeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies evaluation of predicate returning false reports Inactive.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Evaluate_predicate_returning_false_reports_Inactive()
|
|
||||||
{
|
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
|
||||||
alarmId: "alarm-hi",
|
|
||||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
|
||||||
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
|
|
||||||
|
|
||||||
result.Success.ShouldBeTrue(result.Reason);
|
|
||||||
result.Active.ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies compiled predicates are cached across calls.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Evaluate_caches_compiled_predicate_across_calls()
|
|
||||||
{
|
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
|
||||||
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
|
||||||
|
|
||||||
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
|
||||||
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
|
|
||||||
|
|
||||||
first.Active.ShouldBeTrue();
|
|
||||||
second.Active.ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies compile errors return Failure.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Evaluate_compile_error_returns_Failure()
|
|
||||||
{
|
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
|
||||||
|
|
||||||
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
|
||||||
|
|
||||||
result.Success.ShouldBeFalse();
|
|
||||||
result.Reason!.ShouldContain("compile");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies predicate writing virtual tag returns Failure.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
|
||||||
{
|
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.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<string, object?>());
|
|
||||||
|
|
||||||
result.Success.ShouldBeFalse();
|
|
||||||
result.Reason!.ShouldContain("threw");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies empty predicate returns Failure.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Evaluate_empty_predicate_returns_Failure()
|
|
||||||
{
|
|
||||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
|
||||||
|
|
||||||
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies evaluation after dispose returns Failure.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Evaluate_after_dispose_returns_Failure()
|
|
||||||
{
|
|
||||||
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
|
||||||
sut.Dispose();
|
|
||||||
|
|
||||||
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
|
||||||
|
|
||||||
result.Success.ShouldBeFalse();
|
|
||||||
result.Reason!.ShouldContain("disposed");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A predicate's <c>ctx.Logger.Warning(...)</c> call flows through the injected root script
|
|
||||||
/// logger and out the <see cref="ScriptLogTopicSink"/>, producing one
|
|
||||||
/// <see cref="ScriptLogEntry"/> carrying the message, the bound <c>AlarmId</c>, and the
|
|
||||||
/// Warning level.
|
|
||||||
/// </summary>
|
|
||||||
[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<RoslynScriptedAlarmEvaluator>.Instance, new ScriptRootLogger(root));
|
|
||||||
|
|
||||||
var result = sut.Evaluate(
|
|
||||||
alarmId: "alarm-log",
|
|
||||||
predicate: "ctx.Logger.Warning(\"alarm log\"); return true;",
|
|
||||||
dependencies: new Dictionary<string, object?>());
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-176
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>Verifies that full state cycle publishes StateChanged messages to parent at each transition.</summary>
|
|
||||||
[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<ScriptedAlarmActor.StateChanged>();
|
|
||||||
t1.State.ShouldBe(ScriptedAlarmActorState.Active);
|
|
||||||
|
|
||||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("joe"));
|
|
||||||
var t2 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
|
||||||
t2.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
|
||||||
|
|
||||||
actor.Tell(new ScriptedAlarmActor.ConditionCleared());
|
|
||||||
var t3 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
|
||||||
t3.State.ShouldBe(ScriptedAlarmActorState.Inactive);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that duplicate ConditionMet messages in Active state are ignored.</summary>
|
|
||||||
[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<ScriptedAlarmActor.StateChanged>();
|
|
||||||
|
|
||||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("second"));
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that active transition publishes AlarmTransitionEvent to the alerts topic.</summary>
|
|
||||||
[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<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Active);
|
|
||||||
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
var transitionEvt = capture.Payloads.OfType<AlarmTransitionEvent>().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<ScriptLogEntry>().SingleOrDefault();
|
|
||||||
log.ShouldNotBeNull();
|
|
||||||
log.AlarmId.ShouldBe("alarm-7");
|
|
||||||
}, duration: TimeSpan.FromSeconds(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that clear transition publishes Cleared event.</summary>
|
|
||||||
[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<ScriptedAlarmActor.StateChanged>();
|
|
||||||
|
|
||||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 70, DateTime.UtcNow));
|
|
||||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Inactive);
|
|
||||||
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
var kinds = capture.Payloads.OfType<AlarmTransitionEvent>().Select(e => e.TransitionKind).ToList();
|
|
||||||
kinds.ShouldContain("Activated");
|
|
||||||
kinds.ShouldContain("Cleared");
|
|
||||||
}, duration: TimeSpan.FromSeconds(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that manual acknowledge emits Acknowledged transition with the user.</summary>
|
|
||||||
[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<ScriptedAlarmActor.StateChanged>();
|
|
||||||
|
|
||||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane"));
|
|
||||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
|
||||||
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
var ackEvt = capture.Payloads.OfType<AlarmTransitionEvent>()
|
|
||||||
.SingleOrDefault(e => e.TransitionKind == "Acknowledged");
|
|
||||||
ackEvt.ShouldNotBeNull();
|
|
||||||
ackEvt.User.ShouldBe("operator-jane");
|
|
||||||
}, duration: TimeSpan.FromSeconds(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A threshold-based alarm evaluator for testing.</summary>
|
|
||||||
private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator
|
|
||||||
{
|
|
||||||
private readonly double _threshold;
|
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the ThresholdEvaluator class.</summary>
|
|
||||||
/// <param name="threshold">The threshold value to compare against.</param>
|
|
||||||
public ThresholdEvaluator(double threshold) { _threshold = threshold; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public ScriptedAlarmEvalResult Evaluate(string id, string predicate, IReadOnlyDictionary<string, object?> deps)
|
|
||||||
{
|
|
||||||
if (!deps.TryGetValue("temp", out var raw) || raw is null)
|
|
||||||
return ScriptedAlarmEvalResult.Failure("missing temp");
|
|
||||||
return ScriptedAlarmEvalResult.Ok(Convert.ToDouble(raw) > _threshold);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A test publisher that captures published messages.</summary>
|
|
||||||
private sealed class CapturingPublisher
|
|
||||||
{
|
|
||||||
/// <summary>Gets the topics that messages were published to.</summary>
|
|
||||||
public ConcurrentBag<string> Topics { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>Gets the payloads that were published.</summary>
|
|
||||||
public ConcurrentBag<object> Payloads { get; } = new();
|
|
||||||
|
|
||||||
/// <summary>Publishes a message to the specified topic.</summary>
|
|
||||||
/// <param name="topic">The topic name.</param>
|
|
||||||
/// <param name="payload">The message payload.</param>
|
|
||||||
public void Publish(string topic, object payload)
|
|
||||||
{
|
|
||||||
Topics.Add(topic);
|
|
||||||
Payloads.Add(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-157
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>Verifies that alarm state transitions write to the state store with the correct lastAckUser value.</summary>
|
|
||||||
[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<ScriptedAlarmActor.StateChanged>();
|
|
||||||
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<ScriptedAlarmActor.StateChanged>();
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
var ackedSnap = store.Snapshots.Last(s => s.State == "Acknowledged");
|
|
||||||
ackedSnap.LastAckUser.ShouldBe("operator-jane");
|
|
||||||
}, duration: TimeSpan.FromSeconds(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that actor restart restores persisted state so pending acknowledgment is not dropped.</summary>
|
|
||||||
[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<ScriptedAlarmActor.StateChanged>(TimeSpan.FromMilliseconds(500))
|
|
||||||
.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
|
||||||
}, duration: TimeSpan.FromSeconds(3));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that alarm boots to inactive state when no persisted state exists.</summary>
|
|
||||||
[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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that EF-based alarm actor state store correctly persists and restores state through the config database.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task EfAlarmActorStateStore_round_trip_persists_via_ConfigDb()
|
|
||||||
{
|
|
||||||
var db = NewInMemoryDbFactory();
|
|
||||||
var ef = new EfAlarmActorStateStore(db, NullLogger<EfAlarmActorStateStore>.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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that loading an alarm state for a missing ID returns null.</summary>
|
|
||||||
[Fact]
|
|
||||||
public async Task EfAlarmActorStateStore_load_for_missing_id_returns_null()
|
|
||||||
{
|
|
||||||
var db = NewInMemoryDbFactory();
|
|
||||||
var ef = new EfAlarmActorStateStore(db, NullLogger<EfAlarmActorStateStore>.Instance);
|
|
||||||
|
|
||||||
var loaded = await ef.LoadAsync("never-saved", CancellationToken.None);
|
|
||||||
loaded.ShouldBeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class RecordingStateStore : IAlarmActorStateStore
|
|
||||||
{
|
|
||||||
private readonly ConcurrentDictionary<string, AlarmActorStateSnapshot> _byId = new(StringComparer.Ordinal);
|
|
||||||
private readonly ConcurrentQueue<AlarmActorStateSnapshot> _saves = new();
|
|
||||||
|
|
||||||
/// <summary>Gets all saved alarm state snapshots in order.</summary>
|
|
||||||
public List<AlarmActorStateSnapshot> Snapshots => _saves.ToList();
|
|
||||||
|
|
||||||
/// <summary>Loads the alarm state snapshot for the specified alarm ID.</summary>
|
|
||||||
/// <param name="alarmId">The alarm ID.</param>
|
|
||||||
/// <param name="ct">The cancellation token.</param>
|
|
||||||
/// <returns>The alarm state snapshot if found, null otherwise.</returns>
|
|
||||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
|
|
||||||
=> Task.FromResult(_byId.TryGetValue(alarmId, out var v) ? v : null);
|
|
||||||
|
|
||||||
/// <summary>Saves the alarm state snapshot.</summary>
|
|
||||||
/// <param name="snapshot">The alarm state snapshot to save.</param>
|
|
||||||
/// <param name="ct">The cancellation token.</param>
|
|
||||||
/// <returns>A completed task.</returns>
|
|
||||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
|
|
||||||
{
|
|
||||||
_byId[snapshot.AlarmId] = snapshot;
|
|
||||||
_saves.Enqueue(snapshot);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user