feat(runtime): F8/F9 engine evaluator seams + DPS fan-out

VirtualTagActor and ScriptedAlarmActor now route through pluggable
evaluator interfaces and fan out to the cluster's live-tail topics
shipped in F15.3:

- IVirtualTagEvaluator + NullVirtualTagEvaluator in Commons.Engines.
  VirtualTagActor calls evaluator on every DependencyValueChanged,
  dedupes unchanged values, forwards EvaluationResult to its parent,
  and publishes ScriptLogEntry Warning to the script-logs DPS topic
  whenever the evaluator fails.

- IScriptedAlarmEvaluator + NullScriptedAlarmEvaluator. ScriptedAlarmActor
  takes an AlarmConfig (id/name/equipment-path/severity/predicate) and
  publishes both an AlarmTransitionEvent (alerts topic) and a
  ScriptLogEntry (script-logs topic) at every transition. Manual
  ConditionMet/Acknowledge/Cleared still flow through the same
  Transition() so callers without engine bindings still drive the
  state machine; the legacy single-string Props() overload routes
  through a default AlarmConfig.

The Null* defaults keep the actors safe when no engine is bound —
unconfigured nodes never spuriously alarm. Production binding to
Core.VirtualTags.VirtualTagEngine and Core.ScriptedAlarms is the
remaining residual (F8b/F9b — split in tasks JSON).

Tests: Runtime 34 -> 40 (+6):
- VirtualTagActorTests x3 (evaluator drives EvaluationResult,
  unchanged-value dedup, failure publishes Warning ScriptLogEntry)
- ScriptedAlarmActorTests x3 (engine threshold drives Activated +
  Cleared on alerts topic, manual Acknowledge attribution).

All 6 v2 test suites green: 126 tests passing.
This commit is contained in:
Joseph Doherty
2026-05-26 09:05:04 -04:00
parent da141497f8
commit 14fb2b05ed
7 changed files with 507 additions and 49 deletions

View File

@@ -1,60 +1,162 @@
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.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
public enum ScriptedAlarmActorState { Inactive, Active, Acknowledged }
/// <summary>
/// State machine wrapping a single scripted alarm. Transitions:
/// <c>Inactive → Active → Acknowledged → Inactive</c>.
///
/// Engine wiring (compile alarm expression via <c>AlarmConditionService</c>, persist state to
/// <c>ScriptedAlarmState</c> ConfigDb table on <c>PreRestart</c>, emit history rows to
/// <c>HistorianAdapter</c>) is staged for follow-up F9. This skeleton owns the state machine
/// so DriverHostActor can spawn it as a child.
/// 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);
private readonly string _alarmId;
public sealed record AlarmConfig(
string AlarmId,
string AlarmName,
string EquipmentPath,
int Severity,
string? Predicate);
private readonly AlarmConfig _config;
private readonly IScriptedAlarmEvaluator _evaluator;
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;
public static Props Props(
AlarmConfig config,
IScriptedAlarmEvaluator? evaluator = null,
Func<DPSPublisher>? publisherFactory = null) =>
Akka.Actor.Props.Create(() => new ScriptedAlarmActor(
config,
evaluator ?? NullScriptedAlarmEvaluator.Instance,
publisherFactory));
/// <summary>Legacy single-arg ctor kept for callers that only care about the state machine
/// (no engine evaluation, no DPS fan-out). Equivalent to <c>Props(new AlarmConfig(...))</c>.</summary>
public static Props Props(string alarmId) =>
Akka.Actor.Props.Create(() => new ScriptedAlarmActor(alarmId));
Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));
public ScriptedAlarmActor(string alarmId)
public ScriptedAlarmActor(AlarmConfig config, IScriptedAlarmEvaluator evaluator, Func<DPSPublisher>? publisherFactory)
{
_alarmId = alarmId;
_config = config;
_evaluator = evaluator;
_publisherFactory = publisherFactory;
Receive<ConditionMet>(msg =>
{
if (_state != ScriptedAlarmActorState.Inactive) return;
Transition(ScriptedAlarmActorState.Active);
});
Receive<AcknowledgeAlarm>(msg =>
{
if (_state != ScriptedAlarmActorState.Active) return;
Transition(ScriptedAlarmActorState.Acknowledged);
});
Receive<ConditionCleared>(_ =>
{
if (_state == ScriptedAlarmActorState.Inactive) return;
Transition(ScriptedAlarmActorState.Inactive);
});
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"); });
}
private void Transition(ScriptedAlarmActorState next)
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;
_log.Info("ScriptedAlarm {Id}: {From} → {To}", _alarmId, prev, next);
Context.Parent.Tell(new StateChanged(_alarmId, next, DateTime.UtcNow));
// F9: emit history row via HistorianAdapter; persist state to ScriptedAlarmState DB.
_log.Info("ScriptedAlarm {Id}: {From} → {To}", _config.AlarmId, prev, next);
var nowUtc = DateTime.UtcNow;
Context.Parent.Tell(new StateChanged(_config.AlarmId, next, nowUtc));
var kind = next switch
{
ScriptedAlarmActorState.Active => "Activated",
ScriptedAlarmActorState.Acknowledged => "Acknowledged",
ScriptedAlarmActorState.Inactive => "Cleared",
_ => next.ToString(),
};
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));
}
}