refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,673 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Globalization;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-16: Alarm Actor — coordinator actor, child of Instance Actor, peer to Script Actors.
/// Subscribes to attribute change notifications from Instance Actor.
///
/// Evaluates alarm conditions:
/// - ValueMatch: attribute equals a specific value
/// - RangeViolation: attribute outside min/max range
/// - RateOfChange: attribute rate exceeds threshold (configurable window, default per-second)
///
/// State (active/normal) is in memory only, NOT persisted.
/// On restart: starts normal, re-evaluates from incoming values.
///
/// WP-21: AlarmExecutionActor CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no Instance.CallAlarmScript API).
///
/// Supervision: Resume on exception; AlarmExecutionActor stopped on exception.
/// </summary>
public class AlarmActor : ReceiveActor
{
private readonly string _alarmName;
private readonly string _instanceName;
private readonly IActorRef _instanceActor;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly ISiteHealthCollector? _healthCollector;
private AlarmState _currentState = AlarmState.Normal;
/// <summary>
/// Always <see cref="AlarmLevel.None"/> for binary trigger types. For
/// <see cref="AlarmTriggerType.HiLo"/> this is the source of truth — the
/// state machine transitions when the computed level changes.
/// </summary>
private AlarmLevel _currentLevel = AlarmLevel.None;
private readonly AlarmTriggerType _triggerType;
private readonly AlarmEvalConfig _evalConfig;
private readonly int _priority;
private readonly string? _onTriggerScriptName;
private readonly Script<object?>? _onTriggerCompiledScript;
// Expression trigger: compiled expression + the attribute snapshot it
// evaluates against. This field is the single home for the compiled
// expression on the hot path.
private readonly Script<object?>? _compiledTriggerExpression;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
/// <summary>
/// SiteRuntime-017: the exact dictionary instance this actor was seeded from
/// at construction. The Instance Actor must pass a private snapshot here, not
/// its live <c>_attributes</c> field. Exposed for regression coverage of that
/// isolation contract.
/// </summary>
internal IReadOnlyDictionary<string, object?>? SeedAttributesReference { get; }
// Rate of change tracking
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
private readonly TimeSpan _rateOfChangeWindowDuration;
private int _executionCounter;
/// <summary>Initializes a new <see cref="AlarmActor"/> and configures message handlers for the alarm.</summary>
/// <param name="alarmName">The canonical name of this alarm.</param>
/// <param name="instanceName">The name of the owning instance.</param>
/// <param name="instanceActor">Reference to the parent instance actor used for attribute access and script calls.</param>
/// <param name="alarmConfig">The resolved alarm configuration including trigger type, priority, and script references.</param>
/// <param name="onTriggerCompiledScript">Pre-compiled on-trigger script, or <c>null</c> if no script is defined.</param>
/// <param name="sharedScriptLibrary">Shared script library providing common utilities to executed scripts.</param>
/// <param name="options">Site runtime configuration options.</param>
/// <param name="logger">Logger for alarm diagnostics.</param>
/// <param name="compiledTriggerExpression">Pre-compiled trigger expression, or <c>null</c> for non-expression triggers.</param>
/// <param name="initialAttributes">Seed attribute snapshot so static attributes evaluate correctly at startup.</param>
/// <param name="healthCollector">Optional health collector for surfacing alarm execution metrics.</param>
public AlarmActor(
string alarmName,
string instanceName,
IActorRef instanceActor,
ResolvedAlarm alarmConfig,
Script<object?>? onTriggerCompiledScript,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger,
Script<object?>? compiledTriggerExpression = null,
IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = null)
{
_alarmName = alarmName;
_instanceName = instanceName;
_instanceActor = instanceActor;
_sharedScriptLibrary = sharedScriptLibrary;
_options = options;
_logger = logger;
_healthCollector = healthCollector;
_priority = alarmConfig.PriorityLevel;
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
_onTriggerCompiledScript = onTriggerCompiledScript;
_compiledTriggerExpression = compiledTriggerExpression;
// Seed the trigger-expression attribute snapshot from the instance's
// initial attribute set so static attributes (which never re-emit an
// AttributeValueChanged after deploy) evaluate correctly at startup.
SeedAttributesReference = initialAttributes;
if (initialAttributes != null)
{
foreach (var kvp in initialAttributes)
_attributeSnapshot[kvp.Key] = kvp.Value;
}
// Parse trigger type
_triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt)
? tt : AlarmTriggerType.ValueMatch;
_evalConfig = ParseEvalConfig(alarmConfig.TriggerConfiguration);
_rateOfChangeWindowDuration = _evalConfig is RateOfChangeEvalConfig roc
? roc.WindowDuration
: TimeSpan.FromSeconds(1);
// Handle attribute value changes
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle alarm execution completion
Receive<AlarmExecutionCompleted>(_ =>
_logger.LogDebug("Alarm {Alarm} execution completed on {Instance}", _alarmName, _instanceName));
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
_logger.LogInformation(
"AlarmActor {Alarm} started on instance {Instance}, trigger={TriggerType}",
_alarmName, _instanceName, _triggerType);
}
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
_logger.LogWarning(ex,
"AlarmExecutionActor for {Alarm} on {Instance} failed, stopping",
_alarmName, _instanceName);
return Directive.Stop;
}));
}
/// <summary>
/// Evaluates alarm condition on attribute change. Alarm evaluation errors are logged,
/// actor continues (does not crash).
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// Expression triggers evaluate against a snapshot of every attribute,
// not a single monitored attribute. Keep the snapshot current for every
// change before the IsMonitoredAttribute gate (which does not apply).
if (_triggerType == AlarmTriggerType.Expression)
{
_attributeSnapshot[changed.AttributeName] = changed.Value;
}
else if (!IsMonitoredAttribute(changed.AttributeName))
{
// Only evaluate if this change is for an attribute we're monitoring
return;
}
try
{
if (_triggerType == AlarmTriggerType.HiLo)
{
HandleHiLoTransition(EvaluateHiLo(changed.Value));
return;
}
var isTriggered = _triggerType switch
{
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
AlarmTriggerType.Expression => EvaluateExpression(),
_ => false
};
if (isTriggered && _currentState == AlarmState.Normal)
{
// Transition: Normal → Active
_currentState = AlarmState.Active;
_logger.LogInformation(
"Alarm {Alarm} ACTIVATED on instance {Instance}",
_alarmName, _instanceName);
// Notify Instance Actor of alarm state change
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// Spawn AlarmExecutionActor if on-trigger script defined
if (_onTriggerCompiledScript != null)
{
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty);
}
}
else if (!isTriggered && _currentState == AlarmState.Active)
{
// Transition: Active → Normal (no script on clear)
_currentState = AlarmState.Normal;
_logger.LogInformation(
"Alarm {Alarm} CLEARED on instance {Instance}",
_alarmName, _instanceName);
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
}
}
catch (Exception ex)
{
_healthCollector?.IncrementAlarmError();
// Alarm evaluation errors logged, actor continues
_logger.LogError(ex,
"Alarm {Alarm} evaluation error on {Instance}",
_alarmName, _instanceName);
}
}
/// <summary>
/// HiLo state machine: emit an AlarmStateChanged whenever the evaluated
/// level changes. Spawns the on-trigger script only on the Normal→Active
/// edge (i.e., when entering an alarm band from the normal band) — not on
/// level escalations like Hi→HiHi or Low→LowLow.
/// </summary>
private void HandleHiLoTransition(AlarmLevel newLevel)
{
if (newLevel == _currentLevel) return;
var previousLevel = _currentLevel;
_currentLevel = newLevel;
_currentState = newLevel == AlarmLevel.None ? AlarmState.Normal : AlarmState.Active;
var priority = LevelPriority(newLevel);
var message = LevelMessage(newLevel);
_logger.LogInformation(
"Alarm {Alarm} on {Instance} transitioned {Prev} → {New} (priority={Priority})",
_alarmName, _instanceName, previousLevel, newLevel, priority);
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, _currentState, priority, DateTimeOffset.UtcNow)
{
Level = newLevel,
Message = message
};
_instanceActor.Tell(alarmChanged);
if (previousLevel == AlarmLevel.None
&& newLevel != AlarmLevel.None
&& _onTriggerCompiledScript != null)
{
SpawnAlarmExecution(newLevel, priority, message);
}
}
/// <summary>
/// Returns the per-setpoint priority for the given level. Falls back to
/// the alarm-level <see cref="_priority"/> when the HiLo config did not
/// override the priority for that band, or for <see cref="AlarmLevel.None"/>.
/// </summary>
private int LevelPriority(AlarmLevel level)
{
if (_evalConfig is not HiLoEvalConfig hiLo) return _priority;
return level switch
{
AlarmLevel.LowLow => hiLo.LoLoPriority ?? _priority,
AlarmLevel.Low => hiLo.LoPriority ?? _priority,
AlarmLevel.High => hiLo.HiPriority ?? _priority,
AlarmLevel.HighHigh => hiLo.HiHiPriority ?? _priority,
_ => _priority
};
}
/// <summary>
/// Per-band operator message. Empty string when no message is configured
/// for the band, or for non-HiLo trigger types, or for the None level
/// (alarm clear).
/// </summary>
private string LevelMessage(AlarmLevel level)
{
if (_evalConfig is not HiLoEvalConfig hiLo) return string.Empty;
return level switch
{
AlarmLevel.LowLow => hiLo.LoLoMessage ?? string.Empty,
AlarmLevel.Low => hiLo.LoMessage ?? string.Empty,
AlarmLevel.High => hiLo.HiMessage ?? string.Empty,
AlarmLevel.HighHigh => hiLo.HiHiMessage ?? string.Empty,
_ => string.Empty
};
}
private bool IsMonitoredAttribute(string attributeName)
{
return _evalConfig.MonitoredAttributeName == attributeName;
}
private bool EvaluateValueMatch(object? value)
{
if (_evalConfig is not ValueMatchEvalConfig config) return false;
if (config.MatchValue == null) return value == null;
var valueStr = value?.ToString() ?? "";
// Support "!=X" for not-equal matching
if (config.MatchValue.StartsWith("!="))
{
var expected = config.MatchValue[2..];
return !string.Equals(valueStr, expected, StringComparison.Ordinal);
}
return string.Equals(valueStr, config.MatchValue, StringComparison.Ordinal);
}
private bool EvaluateRangeViolation(object? value)
{
if (_evalConfig is not RangeViolationEvalConfig config) return false;
if (value == null) return false;
try
{
// InvariantCulture so string attribute values parse consistently
// regardless of host locale (SiteRuntime-023).
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
return numericValue < config.Min || numericValue > config.Max;
}
catch
{
return false;
}
}
private bool EvaluateRateOfChange(object? value, DateTimeOffset timestamp)
{
if (_evalConfig is not RateOfChangeEvalConfig config) return false;
if (value == null) return false;
try
{
// InvariantCulture so string attribute values parse consistently
// regardless of host locale (SiteRuntime-023).
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
// Add to window
_rateOfChangeWindow.Enqueue((timestamp, numericValue));
// Remove old entries outside the window
var cutoff = timestamp - _rateOfChangeWindowDuration;
while (_rateOfChangeWindow.Count > 0 && _rateOfChangeWindow.Peek().Timestamp < cutoff)
{
_rateOfChangeWindow.Dequeue();
}
if (_rateOfChangeWindow.Count < 2) return false;
var oldest = _rateOfChangeWindow.Peek();
var timeDelta = (timestamp - oldest.Timestamp).TotalSeconds;
if (timeDelta <= 0) return false;
var signedRate = (numericValue - oldest.Value) / timeDelta;
return config.Direction switch
{
RateOfChangeDirection.Rising => signedRate > config.ThresholdPerSecond,
RateOfChangeDirection.Falling => -signedRate > config.ThresholdPerSecond,
_ => Math.Abs(signedRate) > config.ThresholdPerSecond
};
}
catch
{
return false;
}
}
/// <summary>
/// Evaluates the compiled trigger expression against the current attribute
/// snapshot, returning the resulting bool. This bool feeds the existing
/// binary Normal↔Active state path — the alarm is active while true. A
/// throwing, non-bool, or timed-out expression is treated as false (logged
/// as an alarm error) so that the state machine still runs — an Active
/// alarm correctly clears if the expression starts throwing.
/// </summary>
private bool EvaluateExpression()
{
if (_compiledTriggerExpression == null) return false;
try
{
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
// Bound evaluation with a short timeout. The CancellationToken
// covers cooperative/async cases; a pathological CPU-bound
// expression is not fully interruptible. Acceptable because
// trigger expressions are authored by trusted Design-role users
// and are compile-checked pre-deployment.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = _compiledTriggerExpression
.RunAsync(globals, cancellationToken: cts.Token)
.GetAwaiter().GetResult();
return state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too,
// and is correctly treated as false.
_healthCollector?.IncrementAlarmError();
_logger.LogError(ex,
"Alarm {Alarm} trigger expression evaluation failed on {Instance}; treated as false",
_alarmName, _instanceName);
return false;
}
}
/// <summary>
/// HiLo level evaluator: returns the most-severe matching band for the
/// given value. Severity order checked from highest to lowest so that a
/// value at exactly Hi==HiHi resolves to HighHigh. Unset setpoints (null)
/// are skipped, allowing partial configs (e.g., HighHigh only).
///
/// Hysteresis: when the alarm is already in a level whose threshold the
/// value would re-cross from inside, the threshold is relaxed by the
/// configured deadband. This prevents flapping at the boundary — once at
/// HighHigh with HiHi=100 and hiHiDeadband=5, the alarm stays HighHigh
/// until the value drops below 95.
/// </summary>
private AlarmLevel EvaluateHiLo(object? value)
{
if (_evalConfig is not HiLoEvalConfig config) return AlarmLevel.None;
if (value == null) return _currentLevel;
double numericValue;
// InvariantCulture so string attribute values parse consistently
// regardless of host locale (SiteRuntime-023).
try { numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture); }
catch { return _currentLevel; }
// When the current level is at-or-above HighHigh, relax the HiHi exit.
// Same for the other directions.
var hiHiThreshold = config.HiHi;
if (hiHiThreshold is { } hh && _currentLevel == AlarmLevel.HighHigh)
hiHiThreshold = hh - Math.Max(0, config.HiHiDeadband ?? 0);
var hiThreshold = config.Hi;
if (hiThreshold is { } h && (_currentLevel == AlarmLevel.High || _currentLevel == AlarmLevel.HighHigh))
hiThreshold = h - Math.Max(0, config.HiDeadband ?? 0);
var loLoThreshold = config.LoLo;
if (loLoThreshold is { } ll && _currentLevel == AlarmLevel.LowLow)
loLoThreshold = ll + Math.Max(0, config.LoLoDeadband ?? 0);
var loThreshold = config.Lo;
if (loThreshold is { } l && (_currentLevel == AlarmLevel.Low || _currentLevel == AlarmLevel.LowLow))
loThreshold = l + Math.Max(0, config.LoDeadband ?? 0);
if (hiHiThreshold is { } effHiHi && numericValue >= effHiHi) return AlarmLevel.HighHigh;
if (hiThreshold is { } effHi && numericValue >= effHi) return AlarmLevel.High;
if (loLoThreshold is { } effLoLo && numericValue <= effLoLo) return AlarmLevel.LowLow;
if (loThreshold is { } effLo && numericValue <= effLo) return AlarmLevel.Low;
return AlarmLevel.None;
}
/// <summary>
/// Spawns an AlarmExecutionActor to run the on-trigger script.
/// Passes the firing alarm's level/priority/message so the script can
/// branch on severity via the <c>Alarm</c> global.
/// </summary>
private void SpawnAlarmExecution(AlarmLevel level, int priority, string message)
{
if (_onTriggerCompiledScript == null) return;
var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}";
// SiteRuntime-009: the on-trigger script body runs on the dedicated
// ScriptExecutionScheduler, not the shared .NET thread pool.
var props = Props.Create(() => new AlarmExecutionActor(
_alarmName,
_instanceName,
level,
priority,
message,
_onTriggerCompiledScript,
_instanceActor,
_sharedScriptLibrary,
_options,
_logger));
Context.ActorOf(props, executionId);
}
private AlarmEvalConfig ParseEvalConfig(string? triggerConfigJson)
{
if (string.IsNullOrEmpty(triggerConfigJson))
return new ValueMatchEvalConfig("", null);
try
{
var doc = JsonDocument.Parse(triggerConfigJson);
var root = doc.RootElement;
// Support both "attributeName" and "attribute" keys
var attr = root.TryGetProperty("attributeName", out var attrEl)
? attrEl.GetString() ?? ""
: root.TryGetProperty("attribute", out var attrEl2)
? attrEl2.GetString() ?? ""
: "";
return _triggerType switch
{
AlarmTriggerType.ValueMatch => new ValueMatchEvalConfig(
attr,
root.TryGetProperty("matchValue", out var mv) ? mv.GetString()
: root.TryGetProperty("value", out var mv2) ? mv2.GetString()
: null),
AlarmTriggerType.RangeViolation => new RangeViolationEvalConfig(
attr,
root.TryGetProperty("min", out var minEl) ? minEl.GetDouble()
: root.TryGetProperty("low", out var lowEl) ? lowEl.GetDouble()
: double.MinValue,
root.TryGetProperty("max", out var maxEl) ? maxEl.GetDouble()
: root.TryGetProperty("high", out var highEl) ? highEl.GetDouble()
: double.MaxValue),
AlarmTriggerType.RateOfChange => new RateOfChangeEvalConfig(
attr,
root.TryGetProperty("thresholdPerSecond", out var tps) ? tps.GetDouble() : 10.0,
root.TryGetProperty("windowSeconds", out var ws)
? TimeSpan.FromSeconds(ws.GetDouble())
: TimeSpan.FromSeconds(1),
root.TryGetProperty("direction", out var dirEl)
? ParseDirection(dirEl.GetString())
: RateOfChangeDirection.Either),
AlarmTriggerType.HiLo => new HiLoEvalConfig(
attr,
LoLo: TryReadDouble(root, "loLo"),
Lo: TryReadDouble(root, "lo"),
Hi: TryReadDouble(root, "hi"),
HiHi: TryReadDouble(root, "hiHi"),
LoLoPriority: TryReadInt(root, "loLoPriority"),
LoPriority: TryReadInt(root, "loPriority"),
HiPriority: TryReadInt(root, "hiPriority"),
HiHiPriority: TryReadInt(root, "hiHiPriority"),
LoLoDeadband: TryReadDouble(root, "loLoDeadband"),
LoDeadband: TryReadDouble(root, "loDeadband"),
HiDeadband: TryReadDouble(root, "hiDeadband"),
HiHiDeadband: TryReadDouble(root, "hiHiDeadband"),
LoLoMessage: TryReadString(root, "loLoMessage"),
LoMessage: TryReadString(root, "loMessage"),
HiMessage: TryReadString(root, "hiMessage"),
HiHiMessage: TryReadString(root, "hiHiMessage")),
// Expression triggers have no single monitored attribute; they
// evaluate the compiled expression (passed into the actor and
// cached in _compiledTriggerExpression) over the full attribute
// snapshot. MonitoredAttributeName is unused.
AlarmTriggerType.Expression => new ExpressionEvalConfig(
"",
TriggerExpressionGlobals.ExtractExpression(triggerConfigJson) ?? ""),
_ => new ValueMatchEvalConfig(attr, null)
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse alarm trigger config for {Alarm}", _alarmName);
return new ValueMatchEvalConfig("", null);
}
}
private static RateOfChangeDirection ParseDirection(string? raw) => raw?.ToLowerInvariant() switch
{
"rising" or "up" or "positive" => RateOfChangeDirection.Rising,
"falling" or "down" or "negative" => RateOfChangeDirection.Falling,
_ => RateOfChangeDirection.Either
};
private static double? TryReadDouble(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number => p.GetDouble(),
JsonValueKind.String when double.TryParse(p.GetString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
private static int? TryReadInt(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number when p.TryGetInt32(out var i) => i,
JsonValueKind.Number => (int)p.GetDouble(),
JsonValueKind.String when int.TryParse(p.GetString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
private static string? TryReadString(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind == JsonValueKind.String ? p.GetString() : null;
}
// ── Internal messages ──
internal record AlarmExecutionCompleted(string AlarmName, bool Success);
}
internal enum RateOfChangeDirection { Either, Rising, Falling }
// ── Alarm evaluation config types ──
internal abstract record AlarmEvalConfig(string MonitoredAttributeName);
internal record ValueMatchEvalConfig(string MonitoredAttributeName, string? MatchValue) : AlarmEvalConfig(MonitoredAttributeName);
internal record RangeViolationEvalConfig(string MonitoredAttributeName, double Min, double Max) : AlarmEvalConfig(MonitoredAttributeName);
internal record RateOfChangeEvalConfig(
string MonitoredAttributeName,
double ThresholdPerSecond,
TimeSpan WindowDuration,
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
/// <summary>
/// Expression evaluation config: a read-only boolean C# expression evaluated
/// over the full attribute snapshot. Has no single monitored attribute
/// (<see cref="AlarmEvalConfig.MonitoredAttributeName"/> is empty). The
/// compiled expression itself lives on the actor's <c>_compiledTriggerExpression</c>
/// field, the single source for the hot path.
/// </summary>
internal record ExpressionEvalConfig(
string MonitoredAttributeName,
string Expression) : AlarmEvalConfig(MonitoredAttributeName);
/// <summary>
/// HiLo evaluation config: any subset of the four setpoints may be set; null
/// means "don't evaluate that band". Per-setpoint priorities override the
/// alarm-level priority for AlarmStateChanged messages emitted for that band.
/// </summary>
internal record HiLoEvalConfig(
string MonitoredAttributeName,
double? LoLo,
double? Lo,
double? Hi,
double? HiHi,
int? LoLoPriority,
int? LoPriority,
int? HiPriority,
int? HiHiPriority,
double? LoLoDeadband = null,
double? LoDeadband = null,
double? HiDeadband = null,
double? HiHiDeadband = null,
string? LoLoMessage = null,
string? LoMessage = null,
string? HiMessage = null,
string? HiHiMessage = null) : AlarmEvalConfig(MonitoredAttributeName);
@@ -0,0 +1,128 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-16: Alarm Execution Actor -- short-lived child of Alarm Actor.
/// Same pattern as ScriptExecutionActor.
/// WP-21: CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no API for it).
/// Supervision: Stop on unhandled exception.
/// </summary>
public class AlarmExecutionActor : ReceiveActor
{
/// <summary>Initializes a new <see cref="AlarmExecutionActor"/> and immediately schedules execution of the alarm on-trigger script.</summary>
/// <param name="alarmName">The canonical name of the alarm that triggered.</param>
/// <param name="instanceName">The name of the owning instance.</param>
/// <param name="level">The alarm severity level at the time of triggering.</param>
/// <param name="priority">The alarm priority value.</param>
/// <param name="message">The alarm message to pass to the script.</param>
/// <param name="compiledScript">The pre-compiled on-trigger script to execute.</param>
/// <param name="instanceActor">Reference to the parent instance actor for attribute/script calls.</param>
/// <param name="sharedScriptLibrary">Shared script library providing common utilities.</param>
/// <param name="options">Site runtime configuration options, including the execution timeout.</param>
/// <param name="logger">Logger for execution diagnostics.</param>
public AlarmExecutionActor(
string alarmName,
string instanceName,
AlarmLevel level,
int priority,
string message,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger)
{
var self = Self;
var parent = Context.Parent;
ExecuteAlarmScript(
alarmName, instanceName, level, priority, message,
compiledScript, instanceActor,
sharedScriptLibrary, options, self, parent, logger);
}
private static void ExecuteAlarmScript(
string alarmName,
string instanceName,
AlarmLevel level,
int priority,
string message,
Script<object?> compiledScript,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef self,
IActorRef parent,
ILogger logger)
{
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
// SiteRuntime-009: run the alarm on-trigger body on the dedicated
// script-execution scheduler, not the shared .NET thread pool.
var scheduler = ScriptExecutionScheduler.Shared(options);
_ = Task.Factory.StartNew(async () =>
{
using var cts = new CancellationTokenSource(timeout);
try
{
// WP-21: AlarmExecutionActor can call Instance.CallScript()
// via the ScriptRuntimeContext injected into globals
var context = new ScriptRuntimeContext(
instanceActor,
self,
sharedScriptLibrary,
currentCallDepth: 0,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new ScriptParameters(),
CancellationToken = cts.Token,
Alarm = new AlarmContext
{
Name = alarmName,
Level = level,
Priority = priority,
Message = message
}
};
await compiledScript.RunAsync(globals, cts.Token);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, true));
}
catch (OperationCanceledException)
{
logger.LogWarning(
"Alarm on-trigger script for {Alarm} on {Instance} timed out",
alarmName, instanceName);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
catch (Exception ex)
{
// WP-32: Failures logged, alarm continues
logger.LogError(ex,
"Alarm on-trigger script for {Alarm} on {Instance} failed",
alarmName, instanceName);
parent.Tell(new AlarmActor.AlarmExecutionCompleted(alarmName, false));
}
finally
{
self.Tell(PoisonPill.Instance);
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,800 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Represents a single deployed instance at runtime. Holds the in-memory attribute state
/// (loaded from FlattenedConfiguration + static overrides from SQLite).
///
/// The Instance Actor is the single source of truth for runtime instance state.
/// WP-24: All state mutations are serialized through the actor mailbox.
/// Multiple Script Execution Actors run concurrently; state mutations through this actor.
///
/// WP-15/16: Creates child Script Actors and Alarm Actors on startup.
/// WP-22: Tell for tag value updates, attribute notifications, stream publishing.
/// Ask for CallScript, debug snapshot.
/// WP-25: Debug view backend — snapshot + stream subscription.
/// </summary>
public class InstanceActor : ReceiveActor
{
private readonly string _instanceUniqueName;
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteStreamManager? _streamManager;
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
private readonly Dictionary<string, object?> _attributes = new();
private readonly Dictionary<string, string> _attributeQualities = new();
private readonly Dictionary<string, DateTimeOffset> _attributeTimestamps = new();
private readonly Dictionary<string, AlarmState> _alarmStates = new();
private readonly Dictionary<string, DateTimeOffset> _alarmTimestamps = new();
private readonly Dictionary<string, int> _alarmPriorities = new();
private readonly Dictionary<string, IActorRef> _scriptActors = new();
private readonly Dictionary<string, IActorRef> _alarmActors = new();
private FlattenedConfiguration? _configuration;
// DCL manager actor reference for subscribing to tag values
private readonly IActorRef? _dclManager;
// Maps each tag path to every attribute canonical name that references it.
// A tag path can back more than one attribute (e.g. two composed modules
// whose members reference the same PLC node), so a tag value update must
// fan out to all of them — not just the last one registered.
private readonly Dictionary<string, List<string>> _tagPathToAttributes = new();
/// <summary>
/// Initializes the instance actor with its configuration and dependencies.
/// </summary>
/// <param name="instanceUniqueName">System-wide unique name identifying this instance.</param>
/// <param name="configJson">JSON-serialized flattened configuration for this instance.</param>
/// <param name="storage">Site storage service for loading and persisting static overrides.</param>
/// <param name="compilationService">Service used to compile instance scripts.</param>
/// <param name="sharedScriptLibrary">Library of shared scripts available to instance scripts.</param>
/// <param name="streamManager">Optional site stream manager for publishing attribute/alarm changes.</param>
/// <param name="options">Site runtime configuration options.</param>
/// <param name="logger">Logger for this actor.</param>
/// <param name="dclManager">Optional Data Connection Layer manager actor reference.</param>
/// <param name="healthCollector">Optional health collector for reporting metrics.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
public InstanceActor(
string instanceUniqueName,
string configJson,
SiteStorageService storage,
ScriptCompilationService compilationService,
SharedScriptLibrary sharedScriptLibrary,
SiteStreamManager? streamManager,
SiteRuntimeOptions options,
ILogger logger,
IActorRef? dclManager = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
{
_instanceUniqueName = instanceUniqueName;
_storage = storage;
_compilationService = compilationService;
_sharedScriptLibrary = sharedScriptLibrary;
_streamManager = streamManager;
_options = options;
_logger = logger;
_dclManager = dclManager;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
// Deserialize the flattened configuration
_configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
// Load default attribute values from the flattened configuration
// Data-sourced attributes start with Uncertain quality until the first DCL value arrives.
// Static attributes start with Good quality.
if (_configuration != null)
{
foreach (var attr in _configuration.Attributes)
{
_attributes[attr.CanonicalName] = attr.Value;
_attributeQualities[attr.CanonicalName] =
string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
}
}
// Handle attribute queries (Tell pattern -- sender gets response)
Receive<GetAttributeRequest>(HandleGetAttribute);
// Handle static attribute writes
Receive<SetStaticAttributeCommand>(HandleSetStaticAttribute);
// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the
// Deployment Manager — DeploymentManagerActor.HandleDisable/HandleEnable
// stop or re-create the Instance Actor directly and reply to the caller.
// DisableInstanceCommand / EnableInstanceCommand are never routed to the
// Instance Actor, so no handlers are registered here. (The previous no-op
// handlers were dead code that implied a non-existent instance-side
// acknowledgement contract.)
// WP-15: Handle script call requests — route to appropriate Script Actor (Ask pattern)
Receive<ScriptCallRequest>(HandleScriptCallRequest);
// WP-22/23: Handle attribute value changes from DCL (Tell pattern)
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle tag value updates from DCL — convert to AttributeValueChanged
Receive<TagValueUpdate>(HandleTagValueUpdate);
Receive<SubscribeTagsResponse>(_ => { }); // Ack from DCL subscribe — no action needed
Receive<ConnectionQualityChanged>(HandleConnectionQualityChanged);
// WP-16: Handle alarm state changes from Alarm Actors (Tell pattern)
Receive<AlarmStateChanged>(HandleAlarmStateChanged);
// WP-25: Debug view subscribe/unsubscribe (Ask pattern for snapshot)
Receive<SubscribeDebugViewRequest>(HandleSubscribeDebugView);
Receive<UnsubscribeDebugViewRequest>(HandleUnsubscribeDebugView);
// Debug snapshot (one-shot, no subscription)
Receive<DebugSnapshotRequest>(HandleDebugSnapshot);
// Handle internal messages
Receive<LoadOverridesResult>(HandleOverridesLoaded);
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
// Asynchronously load static overrides from SQLite and pipe to self
var self = Self;
_storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return new LoadOverridesResult(t.Result, null);
return new LoadOverridesResult(new Dictionary<string, string>(), t.Exception?.GetBaseException().Message);
}).PipeTo(self);
// Create child Script Actors and Alarm Actors from configuration
CreateChildActors();
// Subscribe to DCL for data-sourced attributes
SubscribeToDcl();
}
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
_logger.LogWarning(ex,
"Child actor on instance {Instance} threw exception, resuming",
_instanceUniqueName);
return Directive.Resume;
}));
}
/// <summary>
/// Returns the current attribute value. Uses Tell pattern; sender gets the response.
/// </summary>
private void HandleGetAttribute(GetAttributeRequest request)
{
var found = _attributes.TryGetValue(request.AttributeName, out var value);
_attributeQualities.TryGetValue(request.AttributeName, out var quality);
Sender.Tell(new GetAttributeResponse(
request.CorrelationId,
_instanceUniqueName,
request.AttributeName,
value,
found,
quality ?? "Good",
DateTimeOffset.UtcNow));
}
/// <summary>
/// Handles an attribute write (<c>Instance.SetAttribute</c> / Inbound API).
/// WP-24: State mutation serialized through this actor's mailbox.
///
/// The write is routed by the attribute's data binding:
/// * Data-sourced attribute → forwards a <see cref="WriteTagRequest"/> to the
/// DCL, which writes the physical device. The in-memory value is NOT
/// optimistically updated and NO static override is persisted — the
/// confirmed device value arrives later via the subscription. Success or
/// failure of the device write is returned to the caller.
/// * Static attribute → updates the in-memory value and persists the override
/// to SQLite.
///
/// Either way the caller receives a <see cref="SetStaticAttributeResponse"/>.
/// </summary>
private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
{
// Resolve the target attribute's data binding from the flattened config.
var resolved = _configuration?.Attributes
.FirstOrDefault(a => a.CanonicalName == command.AttributeName);
// SiteRuntime-025: reject writes targeting an attribute that does not exist
// on the deployed instance. Without this check, an inbound API
// SetAttribute("notARealAttr", ...) would pollute the in-memory
// _attributes dictionary, publish a synthetic AttributeValueChanged to
// debug-view subscribers, and persist a durable static-override row that
// resurrects on every restart. The override row is also outside the
// ClearStaticOverridesAsync window for unknown names. Refuse the write
// and let the caller see the failure, mirroring the script trust model's
// "scripts can only read/write attributes on their own instance" framing.
if (resolved == null)
{
_logger.LogWarning(
"SetAttribute rejected — attribute '{Attribute}' is not defined on instance '{Instance}'",
command.AttributeName, _instanceUniqueName);
Sender.Tell(new SetStaticAttributeResponse(
command.CorrelationId,
_instanceUniqueName,
command.AttributeName,
false,
$"Unknown attribute '{command.AttributeName}'",
DateTimeOffset.UtcNow));
return;
}
var isDataSourced =
!string.IsNullOrEmpty(resolved.DataSourceReference)
&& !string.IsNullOrEmpty(resolved.BoundDataConnectionName);
if (isDataSourced)
{
HandleSetDataAttribute(command, resolved);
return;
}
HandleSetStaticAttributeCore(command);
}
/// <summary>
/// Static attribute write: updates in-memory state, publishes the change,
/// persists the override to SQLite, and replies with success.
/// </summary>
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
{
_attributes[command.AttributeName] = command.Value;
// Publish attribute change to stream (WP-23) and notify children
var changed = new AttributeValueChanged(
_instanceUniqueName,
command.AttributeName,
command.AttributeName,
command.Value,
"Good",
DateTimeOffset.UtcNow);
PublishAndNotifyChildren(changed);
// Persist asynchronously -- fire and forget since the actor is the source of truth.
var instanceName = _instanceUniqueName;
var attributeName = command.AttributeName;
var logger = _logger;
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
.ContinueWith(t =>
{
logger.LogWarning(
t.Exception?.GetBaseException(),
"Failed to persist static override for {Instance}.{Attribute}; in-memory state is authoritative",
instanceName,
attributeName);
}, TaskContinuationOptions.OnlyOnFaulted);
Sender.Tell(new SetStaticAttributeResponse(
command.CorrelationId, _instanceUniqueName, command.AttributeName,
true, null, DateTimeOffset.UtcNow));
}
/// <summary>
/// Data-sourced attribute write: forwards a write request to the DCL and pipes
/// the device write result back to the caller. The in-memory value is left
/// untouched (it is refreshed by the subscription when the device confirms);
/// no static override is persisted for a data-sourced attribute.
/// </summary>
private void HandleSetDataAttribute(SetStaticAttributeCommand command, ResolvedAttribute resolved)
{
var caller = Sender;
var correlationId = command.CorrelationId;
var attributeName = command.AttributeName;
var instanceName = _instanceUniqueName;
if (_dclManager == null)
{
_logger.LogWarning(
"SetAttribute on data-sourced attribute {Instance}.{Attribute} cannot be routed — no DCL manager configured",
instanceName, attributeName);
caller.Tell(new SetStaticAttributeResponse(
correlationId, instanceName, attributeName, false,
"Data Connection Layer not available for write.", DateTimeOffset.UtcNow));
return;
}
var writeRequest = new WriteTagRequest(
correlationId,
resolved.BoundDataConnectionName!,
resolved.DataSourceReference!,
command.Value,
DateTimeOffset.UtcNow);
// Ask the DCL and pipe the result back to the original caller. The DCL
// returns the failure synchronously so the script can handle it.
_dclManager.Ask<WriteTagResponse>(writeRequest, TimeSpan.FromSeconds(30))
.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return new SetStaticAttributeResponse(
correlationId, instanceName, attributeName,
t.Result.Success, t.Result.ErrorMessage, DateTimeOffset.UtcNow);
return new SetStaticAttributeResponse(
correlationId, instanceName, attributeName, false,
t.Exception?.GetBaseException().Message ?? "DCL write timed out",
DateTimeOffset.UtcNow);
}).PipeTo(caller);
}
/// <summary>
/// WP-15: Routes script call requests to the appropriate Script Actor.
/// Uses Ask pattern (WP-22).
/// </summary>
private void HandleScriptCallRequest(ScriptCallRequest request)
{
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
{
// Forward the request to the Script Actor, preserving the original
// sender. The whole record is forwarded unchanged, so any
// ParentExecutionId (Audit Log #23) set by an inbound-API-routed
// call is carried through to the Script Actor verbatim.
scriptActor.Forward(request);
}
else
{
Sender.Tell(new ScriptCallResult(
request.CorrelationId,
false,
null,
$"Script '{request.ScriptName}' not found on instance '{_instanceUniqueName}'."));
}
}
/// <summary>
/// WP-22/23: Handles attribute value changes from DCL or static writes.
/// Updates in-memory state, publishes to stream, and notifies children.
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// WP-24: State mutation serialized through this actor
_attributes[changed.AttributeName] = changed.Value;
_attributeQualities[changed.AttributeName] = changed.Quality;
_attributeTimestamps[changed.AttributeName] = changed.Timestamp;
PublishAndNotifyChildren(changed);
}
/// <summary>
/// Handles tag value updates from DCL. Maps the tag path back to the attribute
/// canonical name and converts to an AttributeValueChanged for unified processing.
/// </summary>
private void HandleTagValueUpdate(TagValueUpdate update)
{
if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
return;
// Normalize array values to JSON strings so they survive Akka serialization
var value = update.Value is Array
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
: update.Value;
// One tag path may back several attributes — update every one of them.
foreach (var attrName in attrNames)
{
var changed = new AttributeValueChanged(
_instanceUniqueName, update.TagPath, attrName,
value, update.Quality.ToString(), update.Timestamp);
HandleAttributeValueChanged(changed);
}
}
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
{
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
qualityChanged.ConnectionName, qualityChanged.Quality, _instanceUniqueName);
if (_configuration == null) return;
// Mark all attributes bound to this connection with the new quality
// and publish to the site stream so the debug view updates in real-time.
// We intentionally do NOT notify script/alarm actors here — the value
// hasn't changed, only the quality, and firing scripts/alarms would
// cause spurious evaluations.
var qualityStr = qualityChanged.Quality.ToString();
foreach (var attr in _configuration.Attributes)
{
if (attr.BoundDataConnectionName == qualityChanged.ConnectionName &&
!string.IsNullOrEmpty(attr.DataSourceReference))
{
_attributeQualities[attr.CanonicalName] = qualityStr;
_attributeTimestamps[attr.CanonicalName] = qualityChanged.Timestamp;
// Publish quality change to stream (current value, new quality)
_attributes.TryGetValue(attr.CanonicalName, out var currentValue);
_streamManager?.PublishAttributeValueChanged(new AttributeValueChanged(
_instanceUniqueName,
attr.DataSourceReference,
attr.CanonicalName,
currentValue,
qualityStr,
qualityChanged.Timestamp));
}
}
}
/// <summary>
/// Subscribes to DCL for all data-sourced attributes. Groups tag paths by connection
/// name and sends SubscribeTagsRequest to the DCL manager.
/// </summary>
private void SubscribeToDcl()
{
if (_dclManager == null || _configuration == null) return;
// Group attributes by their bound connection name
var byConnection = new Dictionary<string, List<string>>();
foreach (var attr in _configuration.Attributes)
{
if (string.IsNullOrEmpty(attr.DataSourceReference) ||
string.IsNullOrEmpty(attr.BoundDataConnectionName))
continue;
// Record every attribute that references this tag path so a single
// tag value update fans out to all of them.
if (!_tagPathToAttributes.TryGetValue(attr.DataSourceReference, out var attrs))
{
attrs = new List<string>();
_tagPathToAttributes[attr.DataSourceReference] = attrs;
}
attrs.Add(attr.CanonicalName);
if (!byConnection.TryGetValue(attr.BoundDataConnectionName, out var connTags))
{
connTags = new List<string>();
byConnection[attr.BoundDataConnectionName] = connTags;
}
// Subscribe each distinct tag path once per connection — a tag shared
// by several attributes still needs only one DCL subscription.
if (!connTags.Contains(attr.DataSourceReference))
connTags.Add(attr.DataSourceReference);
}
// Send subscription requests to DCL for each connection
foreach (var (connectionName, tagPaths) in byConnection)
{
var request = new SubscribeTagsRequest(
Guid.NewGuid().ToString("N"),
_instanceUniqueName,
connectionName,
tagPaths,
DateTimeOffset.UtcNow);
_dclManager.Tell(request, Self);
_logger.LogInformation(
"Instance {Instance} subscribed to {Count} tags on connection {Connection}",
_instanceUniqueName, tagPaths.Count, connectionName);
}
}
/// <summary>
/// WP-16: Handles alarm state changes from Alarm Actors.
/// Updates in-memory alarm state and publishes to stream.
/// </summary>
private void HandleAlarmStateChanged(AlarmStateChanged changed)
{
_alarmStates[changed.AlarmName] = changed.State;
_alarmTimestamps[changed.AlarmName] = changed.Timestamp;
// WP-23: Publish to site-wide stream
_streamManager?.PublishAlarmStateChanged(changed);
}
/// <summary>
/// WP-25: Debug view subscribe — returns snapshot and begins streaming.
/// </summary>
private void HandleSubscribeDebugView(SubscribeDebugViewRequest request)
{
// Build snapshot from current state
var now = DateTimeOffset.UtcNow;
var attributeValues = _attributes.Select(kvp => new AttributeValueChanged(
_instanceUniqueName,
kvp.Key,
kvp.Key,
kvp.Value,
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
_attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();
var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged(
_instanceUniqueName,
name,
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
_alarmPriorities.GetValueOrDefault(name, 0),
_alarmTimestamps[name])).ToList();
var snapshot = new DebugViewSnapshot(
_instanceUniqueName,
attributeValues,
alarmStates,
DateTimeOffset.UtcNow);
Sender.Tell(snapshot);
_logger.LogDebug(
"Debug view snapshot sent for {Instance}, correlationId={Id}",
_instanceUniqueName, request.CorrelationId);
}
/// <summary>
/// WP-25: Debug view unsubscribe (SiteRuntime-013).
/// This handler is a deliberate no-op acknowledgement: the Instance Actor holds
/// no per-subscriber state. The real debug-stream subscription lifecycle lives in
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming.SiteStreamManager"/>
/// (Subscribe / Unsubscribe / RemoveSubscriber); the gRPC stream is torn down
/// there when the central side cancels the call. Nothing is removed here.
/// </summary>
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
{
// No subscription state in the Instance Actor — see the XML doc above.
_logger.LogDebug(
"Debug view unsubscribe for {Instance}, correlationId={Id} " +
"(no-op; subscription teardown handled by SiteStreamManager)",
_instanceUniqueName, request.CorrelationId);
}
/// <summary>
/// One-shot debug snapshot — returns current state without registering a subscriber.
/// </summary>
private void HandleDebugSnapshot(DebugSnapshotRequest request)
{
var now = DateTimeOffset.UtcNow;
var attributeValues = _attributes.Select(kvp => new AttributeValueChanged(
_instanceUniqueName,
kvp.Key,
kvp.Key,
kvp.Value,
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
_attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();
var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged(
_instanceUniqueName,
name,
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
_alarmPriorities.GetValueOrDefault(name, 0),
_alarmTimestamps[name])).ToList();
var snapshot = new DebugViewSnapshot(
_instanceUniqueName,
attributeValues,
alarmStates,
DateTimeOffset.UtcNow);
Sender.Tell(snapshot);
}
/// <summary>
/// Publishes attribute change to stream and notifies child Script/Alarm actors.
/// WP-22: Tell for attribute notifications (fire-and-forget, never blocks).
/// </summary>
private void PublishAndNotifyChildren(AttributeValueChanged changed)
{
// WP-23: Publish to site-wide stream
_streamManager?.PublishAttributeValueChanged(changed);
// Notify Script Actors (for value-change and conditional triggers)
foreach (var scriptActor in _scriptActors.Values)
{
scriptActor.Tell(changed);
}
// Notify Alarm Actors (for alarm evaluation)
foreach (var alarmActor in _alarmActors.Values)
{
alarmActor.Tell(changed);
}
}
/// <summary>
/// Applies static overrides loaded from SQLite on top of default values.
/// </summary>
private void HandleOverridesLoaded(LoadOverridesResult result)
{
if (result.Error != null)
{
_logger.LogWarning(
"Failed to load static overrides for {Instance}: {Error}",
_instanceUniqueName, result.Error);
return;
}
foreach (var kvp in result.Overrides)
{
_attributes[kvp.Key] = kvp.Value;
}
_logger.LogDebug(
"Loaded {Count} static overrides for {Instance}",
result.Overrides.Count, _instanceUniqueName);
}
/// <summary>
/// Creates child Script Actors and Alarm Actors from the flattened configuration.
/// WP-15: Script Actors spawned per script definition.
/// WP-16: Alarm Actors spawned per alarm definition, as peers to Script Actors.
/// WP-32: Compilation errors reject entire instance deployment (logged but actor still starts).
///
/// SiteRuntime-017: each child is seeded from a private point-in-time snapshot
/// of <c>_attributes</c>, NOT the live dictionary. The snapshot is taken here on
/// the Instance Actor thread, so it is race-free; handing the live mutable
/// <see cref="System.Collections.Generic.Dictionary{TKey,TValue}"/> by reference
/// would let a child constructor enumerate it on the child's mailbox thread while
/// this actor mutates it in <c>HandleAttributeValueChanged</c>.
/// </summary>
private void CreateChildActors()
{
if (_configuration == null) return;
// SiteRuntime-017: snapshot the live attribute dictionary once, on the
// Instance Actor thread, before any child is constructed. Each child
// Props closure captures this immutable copy instead of the mutable
// _attributes field, so no child constructor ever enumerates a
// dictionary this actor is concurrently mutating.
var attributeSnapshot = new Dictionary<string, object?>(_attributes);
// Create Script Actors
foreach (var script in _configuration.Scripts)
{
var compilationResult = _compilationService.Compile(script.CanonicalName, script.Code);
if (!compilationResult.IsSuccess)
{
_logger.LogError(
"Script '{Script}' on instance '{Instance}' failed to compile: {Errors}",
script.CanonicalName, _instanceUniqueName,
string.Join("; ", compilationResult.Errors));
continue;
}
// Compile the trigger expression for Expression-triggered scripts.
var triggerExpression = CompileTriggerExpression(
script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}");
var props = Props.Create(() => new ScriptActor(
script.CanonicalName,
_instanceUniqueName,
Self,
compilationResult.CompiledScript,
script,
_sharedScriptLibrary,
_options,
_logger,
triggerExpression,
attributeSnapshot,
_healthCollector,
_serviceProvider));
var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
_scriptActors[script.CanonicalName] = actorRef;
}
// Create Alarm Actors
foreach (var alarm in _configuration.Alarms)
{
Script<object?>? onTriggerScript = null;
// Compile on-trigger script if defined
if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
{
var triggerScriptDef = _configuration.Scripts
.FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName);
if (triggerScriptDef != null)
{
var result = _compilationService.Compile(
$"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code);
if (result.IsSuccess)
{
onTriggerScript = result.CompiledScript;
}
else
{
_logger.LogWarning(
"Alarm trigger script for {Alarm} on {Instance} failed to compile",
alarm.CanonicalName, _instanceUniqueName);
}
}
}
// Compile the trigger expression for Expression-triggered alarms.
var triggerExpression = CompileTriggerExpression(
alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}");
var props = Props.Create(() => new AlarmActor(
alarm.CanonicalName,
_instanceUniqueName,
Self,
alarm,
onTriggerScript,
_sharedScriptLibrary,
_options,
_logger,
triggerExpression,
attributeSnapshot,
_healthCollector));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
_alarmActors[alarm.CanonicalName] = actorRef;
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
}
_logger.LogInformation(
"Instance {Instance}: created {Scripts} script actors and {Alarms} alarm actors",
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
}
/// <summary>
/// Compiles the boolean trigger expression for an Expression-triggered
/// script or alarm. Returns null for non-Expression triggers, a blank
/// expression, or a compilation failure (logged) — in which case the
/// trigger is inert and the actor still starts.
/// </summary>
private Script<object?>? CompileTriggerExpression(
string? triggerType, string? triggerConfigJson, string compileName)
{
if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
return null;
var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson);
if (expression == null)
return null;
var result = _compilationService.CompileTriggerExpression(compileName, expression);
if (result.IsSuccess)
return result.CompiledScript;
_logger.LogError(
"Trigger expression for {Name} on {Instance} failed to compile: {Errors}",
compileName, _instanceUniqueName, string.Join("; ", result.Errors));
return null;
}
/// <summary>
/// Read-only access to current attribute count (for testing/diagnostics).
/// </summary>
public int AttributeCount => _attributes.Count;
/// <summary>
/// Read-only access to script actor count (for testing/diagnostics).
/// </summary>
public int ScriptActorCount => _scriptActors.Count;
/// <summary>
/// Read-only access to alarm actor count (for testing/diagnostics).
/// </summary>
public int AlarmActorCount => _alarmActors.Count;
/// <summary>
/// Internal message for async override loading result.
/// </summary>
internal record LoadOverridesResult(Dictionary<string, string> Overrides, string? Error);
}
@@ -0,0 +1,584 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Globalization;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-15: Script Actor — coordinator actor, child of Instance Actor.
/// Holds compiled script delegate, manages trigger configuration, and spawns
/// ScriptExecutionActor children per invocation. Does not block on child completion.
///
/// Trigger types:
/// - Interval: uses Akka timers to fire periodically
/// - ValueChange: receives attribute change notifications from Instance Actor
/// - Conditional: evaluates a threshold comparison on attribute change
/// - Expression: evaluates a compiled boolean expression on attribute change
/// Conditional and Expression triggers carry a <see cref="TriggerMode"/>:
/// OnTrue fires as the condition becomes true; WhileTrue additionally re-fires
/// on a timer (cadence = MinTimeBetweenRuns) while the condition stays true.
///
/// Supervision strategy: Resume on exception (coordinator preserves state).
/// </summary>
public class ScriptActor : ReceiveActor, IWithTimers
{
private readonly string _scriptName;
private readonly string _instanceName;
private readonly IActorRef _instanceActor;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
private Script<object?>? _compiledScript;
private ScriptTriggerConfig? _triggerConfig;
private TimeSpan? _minTimeBetweenRuns;
private DateTimeOffset _lastExecutionTime = DateTimeOffset.MinValue;
private int _executionCounter;
private readonly Commons.Types.Scripts.ScriptScope _scope;
// Expression trigger state: compiled expression, edge-tracking, and the
// attribute snapshot the expression evaluates against.
private readonly Script<object?>? _compiledTriggerExpression;
private bool _lastExpressionResult;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
// WhileTrue trigger state: the most recent truth value of a Conditional
// trigger's comparison, used to detect false->true / true->false edges.
// (Expression triggers reuse _lastExpressionResult for the same purpose.)
private bool _conditionState;
/// <summary>Timer key for the WhileTrue re-fire timer (cadence = MinTimeBetweenRuns).</summary>
private const string WhileTrueTimerKey = "whiletrue-trigger";
/// <summary>
/// SiteRuntime-017: the exact dictionary instance this actor was seeded from
/// at construction. The Instance Actor must pass a private snapshot here, not
/// its live <c>_attributes</c> field — sharing the live dictionary lets this
/// constructor enumerate it while the Instance Actor mutates it on another
/// thread. Exposed for regression coverage of that isolation contract.
/// </summary>
internal IReadOnlyDictionary<string, object?>? SeedAttributesReference { get; }
/// <summary>Gets or sets the Akka timer scheduler used to schedule interval and WhileTrue triggers.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>
/// Initializes the ScriptActor with its compiled script, trigger configuration, and supporting services.
/// </summary>
/// <param name="scriptName">Name of the script this actor manages.</param>
/// <param name="instanceName">Unique name of the owning instance.</param>
/// <param name="instanceActor">Reference to the parent Instance Actor.</param>
/// <param name="compiledScript">Pre-compiled Roslyn script delegate, or null when compilation failed.</param>
/// <param name="scriptConfig">Resolved script metadata including trigger type and configuration.</param>
/// <param name="sharedScriptLibrary">Library of compiled shared scripts available for inline execution.</param>
/// <param name="options">Site runtime configuration options.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="compiledTriggerExpression">Pre-compiled boolean trigger expression, or null when not an expression trigger.</param>
/// <param name="initialAttributes">Initial attribute snapshot used to seed expression trigger evaluation state.</param>
/// <param name="healthCollector">Optional health metrics collector.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution context services.</param>
public ScriptActor(
string scriptName,
string instanceName,
IActorRef instanceActor,
Script<object?>? compiledScript,
ResolvedScript scriptConfig,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger,
Script<object?>? compiledTriggerExpression = null,
IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
{
_scriptName = scriptName;
_instanceName = instanceName;
_instanceActor = instanceActor;
_compiledScript = compiledScript;
_sharedScriptLibrary = sharedScriptLibrary;
_options = options;
_logger = logger;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
_scope = scriptConfig.Scope;
_compiledTriggerExpression = compiledTriggerExpression;
// Seed the trigger-expression attribute snapshot from the instance's
// initial attribute set so static attributes (which never re-emit an
// AttributeValueChanged after deploy) evaluate correctly at startup.
SeedAttributesReference = initialAttributes;
if (initialAttributes != null)
{
foreach (var kvp in initialAttributes)
_attributeSnapshot[kvp.Key] = kvp.Value;
}
// Parse trigger configuration
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
// Handle script call requests (Ask pattern from Instance Actor or ScriptRuntimeContext)
Receive<ScriptCallRequest>(HandleScriptCallRequest);
// Handle attribute value changes for value-change and conditional triggers
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle interval tick
Receive<IntervalTick>(_ => TrySpawnExecution(null));
// Handle WhileTrue re-fire tick
Receive<WhileTrueTick>(_ => FireWhileTrueTick());
// Handle execution completion (for logging/metrics)
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
// Set up interval trigger if configured
if (_triggerConfig is IntervalTriggerConfig interval)
{
Timers.StartPeriodicTimer(
"interval-trigger",
IntervalTick.Instance,
interval.Interval,
interval.Interval);
_logger.LogDebug(
"ScriptActor {Script} on {Instance}: interval trigger set to {Interval}",
_scriptName, _instanceName, interval.Interval);
}
_logger.LogInformation(
"ScriptActor {Script} started on instance {Instance}",
_scriptName, _instanceName);
}
/// <inheritdoc />
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: TimeSpan.FromMinutes(1),
decider: Decider.From(ex =>
{
_logger.LogWarning(ex,
"ScriptExecutionActor for {Script} on {Instance} failed, stopping",
_scriptName, _instanceName);
return Directive.Stop;
}));
}
/// <summary>
/// Handles CallScript ask from ScriptRuntimeContext or Instance Actor.
/// Spawns a ScriptExecutionActor and forwards the sender for reply.
/// </summary>
private void HandleScriptCallRequest(ScriptCallRequest request)
{
if (_compiledScript == null)
{
Sender.Tell(new ScriptCallResult(
request.CorrelationId,
false,
null,
$"Script '{_scriptName}' is not compiled."));
return;
}
// Audit Log #23 (ParentExecutionId): carry any inbound-routed
// ParentExecutionId through to the ScriptExecutionActor so the routed
// script's ScriptRuntimeContext can record its spawner. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
SpawnExecution(
request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
request.ParentExecutionId);
}
/// <summary>
/// Handles attribute value changes — triggers script if configured for
/// value-change, conditional, or expression. The attribute snapshot is
/// updated for every change before any trigger logic runs.
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// Keep the snapshot current for every change, regardless of trigger type.
_attributeSnapshot[changed.AttributeName] = changed.Value;
if (_triggerConfig is ValueChangeTriggerConfig valueTrigger)
{
if (valueTrigger.AttributeName == changed.AttributeName)
{
TrySpawnExecution(null);
}
}
else if (_triggerConfig is ConditionalTriggerConfig conditional)
{
if (conditional.AttributeName == changed.AttributeName)
{
var conditionMet = EvaluateCondition(conditional, changed.Value);
if (conditional.Mode == TriggerMode.WhileTrue)
{
// Edge-detect against the prior truth value; the timer does
// the repeated firing while the condition stays true.
HandleWhileTrueTransition(conditionMet, _conditionState);
_conditionState = conditionMet;
}
else if (conditionMet)
{
// OnTrue: fire on each matching change (existing behavior).
TrySpawnExecution(null);
}
}
}
else if (_triggerConfig is ExpressionTriggerConfig)
{
EvaluateExpressionTrigger();
}
}
/// <summary>
/// Evaluates the compiled trigger expression against the current attribute
/// snapshot. In <see cref="TriggerMode.OnTrue"/> mode the script runs once
/// per false→true transition; in <see cref="TriggerMode.WhileTrue"/> mode it
/// fires on the edge and the re-fire timer is started/stopped with the
/// expression's truth value. A throwing or non-bool expression is treated as
/// false and logged as a script error; the actor never crashes.
/// </summary>
private void EvaluateExpressionTrigger()
{
if (_compiledTriggerExpression == null) return;
if (_triggerConfig is not ExpressionTriggerConfig exprConfig) return;
bool result;
try
{
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
// Bound evaluation with a short timeout. The CancellationToken
// covers cooperative/async cases; a pathological CPU-bound
// expression is not fully interruptible. Acceptable because
// trigger expressions are authored by trusted Design-role users
// and are compile-checked pre-deployment.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = _compiledTriggerExpression
.RunAsync(globals, cancellationToken: cts.Token)
.GetAwaiter().GetResult();
result = state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too,
// and is correctly treated as false.
LogExpressionError(ex);
result = false;
}
if (exprConfig.Mode == TriggerMode.WhileTrue)
{
HandleWhileTrueTransition(result, _lastExpressionResult);
}
else if (result && !_lastExpressionResult)
{
TrySpawnExecution(null);
}
_lastExpressionResult = result;
}
/// <summary>
/// Applies a WhileTrue trigger's condition-state transition: on the
/// false→true edge, fire once and start the re-fire timer; on the
/// true→false edge, stop the timer. While the state is unchanged, the
/// already-running timer continues to drive re-firing.
/// </summary>
private void HandleWhileTrueTransition(bool nowTrue, bool wasTrue)
{
if (nowTrue && !wasTrue)
{
TrySpawnExecution(null);
StartWhileTrueTimer();
}
else if (!nowTrue && wasTrue)
{
StopWhileTrueTimer();
}
}
/// <summary>
/// Starts the periodic WhileTrue re-fire timer. The cadence is the script's
/// <c>MinTimeBetweenRuns</c>; with none configured the trigger cannot
/// re-fire, so it degrades to the single edge fire and logs a warning.
/// </summary>
private void StartWhileTrueTimer()
{
if (_compiledScript == null) return;
if (_minTimeBetweenRuns is not { } interval)
{
_logger.LogWarning(
"ScriptActor {Script} on {Instance}: WhileTrue trigger has no MinTimeBetweenRuns — " +
"firing once on the edge only, no re-fire timer.",
_scriptName, _instanceName);
return;
}
Timers.StartPeriodicTimer(WhileTrueTimerKey, WhileTrueTick.Instance, interval, interval);
}
/// <summary>Cancels the WhileTrue re-fire timer (a no-op if it is not running).</summary>
private void StopWhileTrueTimer() => Timers.Cancel(WhileTrueTimerKey);
/// <summary>
/// Fires the script for a WhileTrue re-fire tick. The timer interval is
/// itself the cadence, so this spawns directly — bypassing the
/// MinTimeBetweenRuns skip-check that gates change-driven spawns (which
/// could otherwise drop a tick to sub-millisecond timing jitter).
/// </summary>
private void FireWhileTrueTick()
{
if (_compiledScript == null) return;
_lastExecutionTime = DateTimeOffset.UtcNow;
SpawnExecution(null, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString());
}
/// <summary>
/// Records a trigger-expression evaluation failure to the site event log,
/// mirroring how ScriptExecutionActor reports script errors.
/// </summary>
private void LogExpressionError(Exception ex)
{
_healthCollector?.IncrementScriptError();
var errorMsg = $"Trigger expression for script '{_scriptName}' on instance '{_instanceName}' failed: {ex.Message}";
_logger.LogError(ex, "Trigger expression evaluation failed: {Script} on {Instance}", _scriptName, _instanceName);
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
"script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString());
}
/// <summary>
/// Attempts to spawn a script execution, respecting MinTimeBetweenRuns.
/// </summary>
private void TrySpawnExecution(IReadOnlyDictionary<string, object?>? parameters)
{
if (_compiledScript == null) return;
if (_minTimeBetweenRuns.HasValue)
{
var elapsed = DateTimeOffset.UtcNow - _lastExecutionTime;
if (elapsed < _minTimeBetweenRuns.Value)
{
_logger.LogDebug(
"Script {Script} on {Instance}: skipping execution, min time between runs not elapsed ({Elapsed} < {Min})",
_scriptName, _instanceName, elapsed, _minTimeBetweenRuns.Value);
return;
}
}
_lastExecutionTime = DateTimeOffset.UtcNow;
SpawnExecution(parameters, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString());
}
/// <summary>
/// Spawns a new ScriptExecutionActor child for this invocation.
/// Multiple concurrent executions are allowed.
/// </summary>
private void SpawnExecution(
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef replyTo,
string correlationId,
Guid? parentExecutionId = null)
{
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
// SiteRuntime-009: the actor's mailbox stays on the default dispatcher, but the
// script body itself runs on the dedicated ScriptExecutionScheduler (a bounded
// set of dedicated threads), so blocking script I/O is contained there and
// cannot starve the shared .NET thread pool.
var props = Props.Create(() => new ScriptExecutionActor(
_scriptName,
_instanceName,
_compiledScript!,
parameters,
callDepth,
_instanceActor,
_sharedScriptLibrary,
_options,
replyTo,
correlationId,
_logger,
_scope,
_healthCollector,
_serviceProvider,
// Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
// an inbound-API-routed call supplies the inbound request's id.
parentExecutionId));
Context.ActorOf(props, executionId);
}
private void HandleExecutionCompleted(ScriptExecutionCompleted msg)
{
_logger.LogDebug(
"Script {Script} execution completed on {Instance}: success={Success}",
_scriptName, _instanceName, msg.Success);
}
private static bool EvaluateCondition(ConditionalTriggerConfig config, object? value)
{
if (value == null) return false;
try
{
// Use InvariantCulture so a string attribute value like "1.5" parses
// consistently regardless of the host locale (SiteRuntime-023). For
// purely-numeric inputs the culture argument is a no-op, but it is
// safe and future-proof for string-typed attribute values arriving
// from scripts or the data connection layer.
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
return config.Operator switch
{
">" => numericValue > config.Threshold,
">=" => numericValue >= config.Threshold,
"<" => numericValue < config.Threshold,
"<=" => numericValue <= config.Threshold,
"==" => Math.Abs(numericValue - config.Threshold) < 0.0001,
"!=" => Math.Abs(numericValue - config.Threshold) >= 0.0001,
_ => false
};
}
catch
{
return string.Equals(value.ToString(), config.Threshold.ToString(), StringComparison.Ordinal);
}
}
private static ScriptTriggerConfig? ParseTriggerConfig(string? triggerType, string? triggerConfigJson)
{
if (string.IsNullOrEmpty(triggerType)) return null;
return triggerType.ToLowerInvariant() switch
{
"interval" => ParseIntervalTrigger(triggerConfigJson),
"valuechange" => ParseValueChangeTrigger(triggerConfigJson),
"conditional" => ParseConditionalTrigger(triggerConfigJson),
"expression" => ParseExpressionTrigger(triggerConfigJson),
"call" => null, // No automatic trigger — invoked only via Instance.CallScript()
_ => null
};
}
private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json)
{
var expr = TriggerExpressionGlobals.ExtractExpression(json);
if (expr == null) return null;
// ExtractExpression already proved the JSON parses; read the mode too.
var mode = TriggerMode.OnTrue;
try
{
using var doc = JsonDocument.Parse(json!);
mode = ParseTriggerMode(doc.RootElement);
}
catch (JsonException) { /* keep OnTrue */ }
return new ExpressionTriggerConfig(expr, mode);
}
/// <summary>
/// Reads the optional <c>mode</c> field (Conditional + Expression triggers).
/// An absent or unrecognized value (case-insensitive) yields
/// <see cref="TriggerMode.OnTrue"/>, so pre-WhileTrue configs are unchanged.
/// </summary>
private static TriggerMode ParseTriggerMode(JsonElement root)
{
var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
? TriggerMode.WhileTrue
: TriggerMode.OnTrue;
}
private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
try
{
var doc = JsonDocument.Parse(json);
var ms = doc.RootElement.GetProperty("intervalMs").GetInt64();
return new IntervalTriggerConfig(TimeSpan.FromMilliseconds(ms));
}
catch { return null; }
}
private static ValueChangeTriggerConfig? ParseValueChangeTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
try
{
var doc = JsonDocument.Parse(json);
var attr = doc.RootElement.GetProperty("attributeName").GetString()!;
return new ValueChangeTriggerConfig(attr);
}
catch { return null; }
}
private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
try
{
var doc = JsonDocument.Parse(json);
var attr = doc.RootElement.GetProperty("attributeName").GetString()!;
var op = doc.RootElement.GetProperty("operator").GetString()!;
var threshold = doc.RootElement.GetProperty("threshold").GetDouble();
return new ConditionalTriggerConfig(
attr, op, threshold, ParseTriggerMode(doc.RootElement));
}
catch { return null; }
}
// ── Internal messages ──
internal sealed class IntervalTick
{
public static readonly IntervalTick Instance = new();
private IntervalTick() { }
}
internal sealed class WhileTrueTick
{
public static readonly WhileTrueTick Instance = new();
private WhileTrueTick() { }
}
internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error);
}
// ── Trigger config types ──
/// <summary>
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
/// as the condition becomes true; <see cref="WhileTrue"/> additionally re-fires
/// on a timer (cadence = the script's MinTimeBetweenRuns) until it goes false.
/// </summary>
internal enum TriggerMode { OnTrue, WhileTrue }
internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold, TriggerMode Mode) : ScriptTriggerConfig;
internal record ExpressionTriggerConfig(string Expression, TriggerMode Mode) : ScriptTriggerConfig;
internal abstract record ScriptTriggerConfig;
@@ -0,0 +1,274 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-15: Script Execution Actor -- short-lived child of Script Actor.
/// Receives compiled code, params, Instance Actor ref, and call depth.
/// Executes the script via Script Runtime API, returns result, then stops.
///
/// The actor itself and its mailbox run on the default Akka dispatcher; only the
/// script body is dispatched off the actor thread, onto the dedicated
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptExecutionScheduler"/>
/// (SiteRuntime-009), so blocking script I/O cannot starve the shared thread pool
/// or stall other Akka dispatchers.
///
/// WP-32: Script failures are logged but do not disable the script.
/// Supervision: Stop on unhandled exception (parent ScriptActor decides).
/// </summary>
public class ScriptExecutionActor : ReceiveActor
{
/// <summary>
/// Initializes the actor and immediately begins script execution on construction.
/// </summary>
/// <param name="scriptName">Name of the script being executed.</param>
/// <param name="instanceName">Name of the instance that owns the script.</param>
/// <param name="compiledScript">Compiled Roslyn script to execute.</param>
/// <param name="parameters">Optional named parameter values for the script.</param>
/// <param name="callDepth">Current call-nesting depth (used to enforce the max-depth limit).</param>
/// <param name="instanceActor">Parent instance actor reference for attribute access.</param>
/// <param name="sharedScriptLibrary">Library of shared scripts available during execution.</param>
/// <param name="options">Site runtime options applied during execution.</param>
/// <param name="replyTo">Actor reference that receives the script result.</param>
/// <param name="correlationId">Application-level correlation id threaded through the execution.</param>
/// <param name="logger">Logger for script execution events.</param>
/// <param name="scope">Script scope controlling which APIs are available.</param>
/// <param name="healthCollector">Optional health collector for recording execution metrics.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
/// <param name="parentExecutionId">ExecutionId of the spawning inbound-API execution for audit correlation; null for normal runs.</param>
public ScriptExecutionActor(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// ExecutionId for an inbound-API-routed call. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
Guid? parentExecutionId = null)
{
// Immediately begin execution
var self = Self;
var parent = Context.Parent;
ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, scope, healthCollector, serviceProvider,
parentExecutionId);
}
private static void ExecuteScript(
string scriptName,
string instanceName,
Script<object?> compiledScript,
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
IActorRef replyTo,
string correlationId,
IActorRef self,
IActorRef parent,
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider,
Guid? parentExecutionId)
{
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
// SiteRuntime-009: run the script body on the dedicated script-execution
// scheduler, not the shared .NET thread pool, so blocking script I/O cannot
// starve the global pool and stall Akka dispatchers / HTTP handling.
var scheduler = ScriptExecutionScheduler.Shared(options);
// Notification Outbox: the site communication actor that Notify.Status queries
// central through. Resolved by actor path so the Notify helper does not need an
// IActorRef threaded all the way down from the host wiring.
var siteCommunicationActor = Context.System.ActorSelection("/user/site-communication");
// CTS must be created inside the async lambda so it outlives this method
_ = Task.Factory.StartNew(async () =>
{
IServiceScope? serviceScope = null;
// ISiteEventLogger is a singleton; resolve from the root provider so
// it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
using var cts = new CancellationTokenSource(timeout);
try
{
// Resolve integration services from DI (scoped lifetime)
IExternalSystemClient? externalSystemClient = null;
IDatabaseGateway? databaseGateway = null;
// Notification Outbox: the S&F engine is a singleton; the site identity
// provider supplies the site id stamped on enqueued notifications.
StoreAndForwardService? storeAndForward = null;
var siteId = string.Empty;
// Audit Log #23 (M2 Bundle F): the writer is a singleton (FallbackAuditWriter
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
// that haven't called AddAuditLog, which the helper handles as a no-op.
IAuditWriter? auditWriter = null;
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
// backing Tracking.Status(id). Singleton; null in tests / hosts
// that haven't wired the store, which the helper handles by
// throwing on access.
IOperationTrackingStore? operationTrackingStore = null;
// Audit Log #23 (M3 Bundle F — Task F1): site-side cached-call
// telemetry forwarder. Singleton bound to the AuditLog
// composition root; null in tests / hosts that haven't called
// AddAuditLog, in which case the cached-call helpers degrade
// to the no-emission path (the underlying S&F handoff still
// happens and a TrackedOperationId is still returned).
ICachedCallTelemetryForwarder? cachedForwarder = null;
// SourceNode-stamping (Tasks 13/14): the local node name
// resolved from INodeIdentityProvider — node-a/node-b on site
// hosts. Null in tests / hosts that haven't registered the
// provider, in which case NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode stay null and central
// persists the rows with SourceNode NULL.
string? sourceNode = null;
if (serviceProvider != null)
{
serviceScope = serviceProvider.CreateScope();
externalSystemClient = serviceScope.ServiceProvider.GetService<IExternalSystemClient>();
databaseGateway = serviceScope.ServiceProvider.GetService<IDatabaseGateway>();
storeAndForward = serviceScope.ServiceProvider.GetService<StoreAndForwardService>();
siteId = serviceScope.ServiceProvider.GetService<ISiteIdentityProvider>()?.SiteId
?? string.Empty;
auditWriter = serviceScope.ServiceProvider.GetService<IAuditWriter>();
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
}
var context = new ScriptRuntimeContext(
instanceActor,
self,
sharedScriptLibrary,
callDepth,
options.MaxScriptCallDepth,
timeout,
instanceName,
logger,
externalSystemClient,
databaseGateway,
storeAndForward,
siteCommunicationActor,
siteId,
// Notification Outbox (FU3): stamp the executing script onto outbound
// notifications using the Site Event Logging "Source" convention.
sourceScript: $"ScriptActor:{scriptName}",
// Audit Log #23 (M2 Bundle F): emit one ApiOutbound/ApiCall row per
// ExternalSystem.Call. Writer is best-effort; failures are logged
// and swallowed inside the helper so the script's call path is
// never aborted by an audit failure.
auditWriter: auditWriter,
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
// backing Tracking.Status(id). Authoritative source of truth for
// cached-call status — read directly by the script API.
operationTrackingStore: operationTrackingStore,
// Audit Log #23 (M3 Bundle F — Task F1): cached-call telemetry
// forwarder for ExternalSystem.CachedCall / Database.CachedWrite
// CachedSubmit emission + the immediate-success terminal-row
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
cachedForwarder: cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id for an inbound-API-routed call. The routed script still
// mints its own fresh ExecutionId — this records the spawner.
// Null for normal (tag-change / timer) runs.
parentExecutionId: parentExecutionId,
// SourceNode-stamping (Tasks 13/14): the local node name
// (node-a/node-b on a site) — threaded down so Notify.Send
// and the four cached-call telemetry constructors can stamp
// it onto NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode respectively.
sourceNode: sourceNode);
var globals = new ScriptGlobals
{
Instance = context,
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
CancellationToken = cts.Token,
Scope = scope
};
var state = await compiledScript.RunAsync(globals, cts.Token);
// Send result to requester if this was an Ask-based call
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null));
}
// Notify parent of completion
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, true, null));
}
catch (OperationCanceledException)
{
healthCollector?.IncrementScriptError();
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
logger.LogWarning(errorMsg);
// WP-32: Failures recorded to site event log; script NOT disabled after failure.
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
catch (Exception ex)
{
healthCollector?.IncrementScriptError();
// WP-32: Failures recorded to site event log; script NOT disabled after failure.
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName);
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
if (!replyTo.IsNobody())
{
replyTo.Tell(new ScriptCallResult(correlationId, false, null, errorMsg));
}
parent.Tell(new ScriptActor.ScriptExecutionCompleted(scriptName, false, errorMsg));
}
finally
{
// Dispose the DI scope (and scoped services) after script execution completes
serviceScope?.Dispose();
// Stop self after execution completes
self.Tell(PoisonPill.Instance);
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
}
}
@@ -0,0 +1,224 @@
using Akka.Actor;
using Akka.Cluster;
using Akka.Event;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Runs on every site node (not a singleton). Handles both config and S&amp;F replication
/// between site cluster peers.
///
/// Outbound: receives local replication requests and forwards to peer via ActorSelection.
/// Inbound: receives replicated operations from peer and applies to local SQLite.
/// Uses fire-and-forget (Tell) — no ack wait per design.
/// </summary>
public class SiteReplicationActor : ReceiveActor
{
private readonly SiteStorageService _storage;
private readonly StoreAndForwardStorage _sfStorage;
private readonly ReplicationService _replicationService;
private readonly string _siteRole;
private readonly ILogger<SiteReplicationActor> _logger;
private readonly Cluster _cluster;
private Address? _peerAddress;
/// <summary>
/// Initializes a new <see cref="SiteReplicationActor"/> and registers Akka message handlers.
/// </summary>
/// <param name="storage">Service for accessing local site storage.</param>
/// <param name="sfStorage">Store-and-forward SQLite storage for replication of buffered messages.</param>
/// <param name="replicationService">Service providing replication transport logic.</param>
/// <param name="siteRole">Akka cluster role used to identify peer nodes to replicate to.</param>
/// <param name="logger">Logger instance.</param>
public SiteReplicationActor(
SiteStorageService storage,
StoreAndForwardStorage sfStorage,
ReplicationService replicationService,
string siteRole,
ILogger<SiteReplicationActor> logger)
{
_storage = storage;
_sfStorage = sfStorage;
_replicationService = replicationService;
_siteRole = siteRole;
_logger = logger;
_cluster = Cluster.Get(Context.System);
// Cluster member events
Receive<ClusterEvent.MemberUp>(HandleMemberUp);
Receive<ClusterEvent.MemberRemoved>(HandleMemberRemoved);
Receive<ClusterEvent.CurrentClusterState>(HandleCurrentClusterState);
// Outbound — forward to peer
Receive<ReplicateConfigDeploy>(msg => SendToPeer(new ApplyConfigDeploy(
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)));
Receive<ReplicateConfigRemove>(msg => SendToPeer(new ApplyConfigRemove(msg.InstanceName)));
Receive<ReplicateConfigSetEnabled>(msg => SendToPeer(new ApplyConfigSetEnabled(
msg.InstanceName, msg.IsEnabled)));
Receive<ReplicateArtifacts>(msg => SendToPeer(new ApplyArtifacts(msg.Command)));
Receive<ReplicateStoreAndForward>(msg => SendToPeer(new ApplyStoreAndForward(msg.Operation)));
// Inbound — apply from peer
Receive<ApplyConfigDeploy>(HandleApplyConfigDeploy);
Receive<ApplyConfigRemove>(HandleApplyConfigRemove);
Receive<ApplyConfigSetEnabled>(HandleApplyConfigSetEnabled);
Receive<ApplyArtifacts>(HandleApplyArtifacts);
Receive<ApplyStoreAndForward>(HandleApplyStoreAndForward);
}
/// <inheritdoc />
protected override void PreStart()
{
base.PreStart();
_cluster.Subscribe(Self, ClusterEvent.SubscriptionInitialStateMode.InitialStateAsSnapshot,
typeof(ClusterEvent.MemberUp),
typeof(ClusterEvent.MemberRemoved));
_logger.LogInformation("SiteReplicationActor started, subscribing to cluster events for role {Role}", _siteRole);
}
/// <inheritdoc />
protected override void PostStop()
{
_cluster.Unsubscribe(Self);
base.PostStop();
}
private void HandleCurrentClusterState(ClusterEvent.CurrentClusterState state)
{
foreach (var member in state.Members)
{
if (member.Status == MemberStatus.Up)
TryTrackPeer(member);
}
}
private void HandleMemberUp(ClusterEvent.MemberUp evt)
{
TryTrackPeer(evt.Member);
}
private void HandleMemberRemoved(ClusterEvent.MemberRemoved evt)
{
if (evt.Member.Address.Equals(_peerAddress))
{
_logger.LogInformation("Peer node removed: {Address}", _peerAddress);
_peerAddress = null;
}
}
private void TryTrackPeer(Member member)
{
// Must have our site role, and must not be self
if (member.HasRole(_siteRole) && !member.Address.Equals(_cluster.SelfAddress))
{
_peerAddress = member.Address;
_logger.LogInformation("Peer node tracked: {Address}", _peerAddress);
}
}
private void SendToPeer(object message)
{
if (_peerAddress == null)
{
_logger.LogDebug("No peer available, dropping replication message {Type}", message.GetType().Name);
return;
}
var path = new RootActorPath(_peerAddress) / "user" / "site-replication";
Context.ActorSelection(path).Tell(message);
}
// ── Inbound handlers ──
private void HandleApplyConfigDeploy(ApplyConfigDeploy msg)
{
_logger.LogInformation("Applying replicated config deploy for {Instance}", msg.InstanceName);
_storage.StoreDeployedConfigAsync(
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated deploy for {Instance}", msg.InstanceName);
});
}
private void HandleApplyConfigRemove(ApplyConfigRemove msg)
{
_logger.LogInformation("Applying replicated config remove for {Instance}", msg.InstanceName);
_storage.RemoveDeployedConfigAsync(msg.InstanceName)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated remove for {Instance}", msg.InstanceName);
});
}
private void HandleApplyConfigSetEnabled(ApplyConfigSetEnabled msg)
{
_logger.LogInformation("Applying replicated set-enabled={Enabled} for {Instance}", msg.IsEnabled, msg.InstanceName);
_storage.SetInstanceEnabledAsync(msg.InstanceName, msg.IsEnabled)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated set-enabled for {Instance}", msg.InstanceName);
});
}
private void HandleApplyArtifacts(ApplyArtifacts msg)
{
var command = msg.Command;
_logger.LogInformation("Applying replicated artifacts, deploymentId={DeploymentId}", command.DeploymentId);
Task.Run(async () =>
{
try
{
if (command.SharedScripts != null)
foreach (var s in command.SharedScripts)
await _storage.StoreSharedScriptAsync(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition);
if (command.ExternalSystems != null)
foreach (var es in command.ExternalSystems)
await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl, es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson);
if (command.DatabaseConnections != null)
foreach (var db in command.DatabaseConnections)
await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString, db.MaxRetries, db.RetryDelay);
if (command.NotificationLists != null)
foreach (var nl in command.NotificationLists)
await _storage.StoreNotificationListAsync(nl.Name, nl.RecipientEmails);
if (command.DataConnections != null)
foreach (var dc in command.DataConnections)
await _storage.StoreDataConnectionDefinitionAsync(dc.Name, dc.Protocol, dc.PrimaryConfigurationJson, dc.BackupConfigurationJson, dc.FailoverRetryCount);
if (command.SmtpConfigurations != null)
foreach (var smtp in command.SmtpConfigurations)
await _storage.StoreSmtpConfigurationAsync(smtp.Name, smtp.Server, smtp.Port, smtp.AuthMode,
smtp.FromAddress, smtp.Username, smtp.Password, smtp.OAuthConfig);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply replicated artifacts");
}
});
}
private void HandleApplyStoreAndForward(ApplyStoreAndForward msg)
{
_logger.LogDebug("Applying replicated S&F operation {OpType} for message {Id}",
msg.Operation.OperationType, msg.Operation.MessageId);
_replicationService.ApplyReplicatedOperationAsync(msg.Operation, _sfStorage)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to apply replicated S&F operation {Id}", msg.Operation.MessageId);
});
}
}