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:
@@ -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&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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
|
||||
|
||||
// Outbound messages — sent by local DeploymentManagerActor/S&F service
|
||||
// to the local SiteReplicationActor for forwarding to the peer node.
|
||||
|
||||
/// <summary>Outbound: replicate a deployed instance config (create or update) to the peer node.</summary>
|
||||
public record ReplicateConfigDeploy(
|
||||
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
|
||||
|
||||
/// <summary>Outbound: replicate removal of a deployed instance config to the peer node.</summary>
|
||||
public record ReplicateConfigRemove(string InstanceName);
|
||||
|
||||
/// <summary>Outbound: replicate an instance enabled/disabled flag change to the peer node.</summary>
|
||||
public record ReplicateConfigSetEnabled(string InstanceName, bool IsEnabled);
|
||||
|
||||
/// <summary>Outbound: replicate a system-wide artifact deployment (shared scripts, external systems, etc.) to the peer node.</summary>
|
||||
public record ReplicateArtifacts(DeployArtifactsCommand Command);
|
||||
|
||||
/// <summary>Outbound: replicate a store-and-forward buffer mutation (enqueue/dequeue/park/etc.) to the peer node.</summary>
|
||||
public record ReplicateStoreAndForward(ReplicationOperation Operation);
|
||||
|
||||
// Inbound messages — received from the peer's SiteReplicationActor
|
||||
// and applied to local SQLite storage.
|
||||
|
||||
/// <summary>Inbound: apply a peer-replicated instance config (create or update) to local SQLite.</summary>
|
||||
public record ApplyConfigDeploy(
|
||||
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
|
||||
|
||||
/// <summary>Inbound: apply peer-replicated removal of a deployed instance config to local SQLite.</summary>
|
||||
public record ApplyConfigRemove(string InstanceName);
|
||||
|
||||
/// <summary>Inbound: apply a peer-replicated instance enabled/disabled flag change to local SQLite.</summary>
|
||||
public record ApplyConfigSetEnabled(string InstanceName, bool IsEnabled);
|
||||
|
||||
/// <summary>Inbound: apply a peer-replicated system-wide artifact deployment to local SQLite.</summary>
|
||||
public record ApplyArtifacts(DeployArtifactsCommand Command);
|
||||
|
||||
/// <summary>Inbound: apply a peer-replicated store-and-forward buffer mutation to the local buffer.</summary>
|
||||
public record ApplyStoreAndForward(ReplicationOperation Operation);
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that initializes the SQLite schema on startup.
|
||||
/// Runs before the Akka actor system starts creating actors.
|
||||
/// </summary>
|
||||
public class SiteStorageInitializer : IHostedService
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SiteStorageInitializer"/>.
|
||||
/// </summary>
|
||||
/// <param name="storage">The site storage service whose schema is initialized on startup.</param>
|
||||
public SiteStorageInitializer(SiteStorageService storage)
|
||||
{
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the SQLite schema before the actor system starts.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the startup operation.</param>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _storage.InitializeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op stop; schema initialization does not require cleanup.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Unused cancellation token.</param>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Direct SQLite persistence for site-local deployment state.
|
||||
/// Stores deployed instance configurations (as JSON) and static attribute overrides.
|
||||
/// This is NOT EF Core — uses Microsoft.Data.Sqlite directly for lightweight site storage.
|
||||
/// </summary>
|
||||
public class SiteStorageService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<SiteStorageService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SiteStorageService with the specified SQLite connection string and logger.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">SQLite connection string for the site database.</param>
|
||||
/// <param name="logger">Logger instance for diagnostic messages.</param>
|
||||
public SiteStorageService(string connectionString, ILogger<SiteStorageService> logger)
|
||||
{
|
||||
_connectionString = connectionString;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new (unopened) SQLite connection against the site database.
|
||||
/// Exposed so site-local repositories can open their own connections without
|
||||
/// reaching into private state via reflection (SiteRuntime-006). The caller owns
|
||||
/// the connection and is responsible for opening and disposing it.
|
||||
/// </summary>
|
||||
public SqliteConnection CreateConnection() => new(_connectionString);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the SQLite tables if they do not exist.
|
||||
/// Called once on site startup.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS deployed_configurations (
|
||||
instance_unique_name TEXT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
revision_hash TEXT NOT NULL,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
deployed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS static_attribute_overrides (
|
||||
instance_unique_name TEXT NOT NULL,
|
||||
attribute_name TEXT NOT NULL,
|
||||
override_value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (instance_unique_name, attribute_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_scripts (
|
||||
name TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
parameter_definitions TEXT,
|
||||
return_definition TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS external_systems (
|
||||
name TEXT PRIMARY KEY,
|
||||
endpoint_url TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL,
|
||||
auth_configuration TEXT,
|
||||
method_definitions TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS database_connections (
|
||||
name TEXT PRIMARY KEY,
|
||||
connection_string TEXT NOT NULL,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
retry_delay_ms INTEGER NOT NULL DEFAULT 1000,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_lists (
|
||||
name TEXT PRIMARY KEY,
|
||||
recipient_emails TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_connection_definitions (
|
||||
name TEXT PRIMARY KEY,
|
||||
protocol TEXT NOT NULL,
|
||||
configuration TEXT,
|
||||
backup_configuration TEXT,
|
||||
failover_retry_count INTEGER NOT NULL DEFAULT 3,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS smtp_configurations (
|
||||
name TEXT PRIMARY KEY,
|
||||
server TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
auth_mode TEXT NOT NULL,
|
||||
from_address TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
oauth_config TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
|
||||
// Schema migrations — add columns that may not exist on older databases
|
||||
await MigrateSchemaAsync(connection);
|
||||
|
||||
_logger.LogInformation("Site SQLite storage initialized at {ConnectionString}", _connectionString);
|
||||
}
|
||||
|
||||
private async Task MigrateSchemaAsync(SqliteConnection connection)
|
||||
{
|
||||
// Add backup_configuration and failover_retry_count to data_connection_definitions
|
||||
// (added in primary/backup data connections feature)
|
||||
await TryAddColumnAsync(connection, "data_connection_definitions", "backup_configuration", "TEXT");
|
||||
await TryAddColumnAsync(connection, "data_connection_definitions", "failover_retry_count", "INTEGER NOT NULL DEFAULT 3");
|
||||
}
|
||||
|
||||
private async Task TryAddColumnAsync(SqliteConnection connection, string table, string column, string type)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
_logger.LogInformation("Migrated: added column {Column} to {Table}", column, table);
|
||||
}
|
||||
catch (SqliteException ex) when (ex.Message.Contains("duplicate column"))
|
||||
{
|
||||
// Column already exists — no action needed
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deployed Configuration CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns all deployed instance configurations from SQLite.
|
||||
/// </summary>
|
||||
public async Task<List<DeployedInstance>> GetAllDeployedConfigsAsync()
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at
|
||||
FROM deployed_configurations";
|
||||
|
||||
var results = new List<DeployedInstance>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(new DeployedInstance
|
||||
{
|
||||
InstanceUniqueName = reader.GetString(0),
|
||||
ConfigJson = reader.GetString(1),
|
||||
DeploymentId = reader.GetString(2),
|
||||
RevisionHash = reader.GetString(3),
|
||||
IsEnabled = reader.GetInt64(4) != 0,
|
||||
DeployedAt = reader.GetString(5)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a deployed instance configuration. Uses UPSERT semantics.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">The unique name of the instance.</param>
|
||||
/// <param name="configJson">The deployed configuration as JSON.</param>
|
||||
/// <param name="deploymentId">The unique deployment identifier.</param>
|
||||
/// <param name="revisionHash">The configuration revision hash for staleness detection.</param>
|
||||
/// <param name="isEnabled">Whether the instance is enabled.</param>
|
||||
public async Task StoreDeployedConfigAsync(
|
||||
string instanceName,
|
||||
string configJson,
|
||||
string deploymentId,
|
||||
string revisionHash,
|
||||
bool isEnabled)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO deployed_configurations (instance_unique_name, config_json, deployment_id, revision_hash, is_enabled, deployed_at)
|
||||
VALUES (@name, @json, @depId, @hash, @enabled, @deployedAt)
|
||||
ON CONFLICT(instance_unique_name) DO UPDATE SET
|
||||
config_json = excluded.config_json,
|
||||
deployment_id = excluded.deployment_id,
|
||||
revision_hash = excluded.revision_hash,
|
||||
is_enabled = excluded.is_enabled,
|
||||
deployed_at = excluded.deployed_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
command.Parameters.AddWithValue("@json", configJson);
|
||||
command.Parameters.AddWithValue("@depId", deploymentId);
|
||||
command.Parameters.AddWithValue("@hash", revisionHash);
|
||||
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@deployedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_logger.LogDebug("Stored deployed config for {Instance}, deploymentId={DeploymentId}", instanceName, deploymentId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a deployed instance configuration and its static overrides.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">The unique name of the instance to remove.</param>
|
||||
public async Task RemoveDeployedConfigAsync(string instanceName)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = (SqliteTransaction)transaction;
|
||||
cmd.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
|
||||
cmd.Parameters.AddWithValue("@name", instanceName);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var cmd = connection.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = (SqliteTransaction)transaction;
|
||||
cmd.CommandText = "DELETE FROM deployed_configurations WHERE instance_unique_name = @name";
|
||||
cmd.Parameters.AddWithValue("@name", instanceName);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
_logger.LogInformation("Removed deployed config and overrides for {Instance}", instanceName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the enabled/disabled state of a deployed instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">The unique name of the instance.</param>
|
||||
/// <param name="isEnabled">Whether the instance should be enabled.</param>
|
||||
public async Task SetInstanceEnabledAsync(string instanceName, bool isEnabled)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
UPDATE deployed_configurations
|
||||
SET is_enabled = @enabled
|
||||
WHERE instance_unique_name = @name";
|
||||
|
||||
command.Parameters.AddWithValue("@enabled", isEnabled ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync();
|
||||
if (rows == 0)
|
||||
{
|
||||
_logger.LogWarning("SetInstanceEnabled: instance {Instance} not found", instanceName);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Static Attribute Override CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns all static attribute overrides for an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">The unique name of the instance.</param>
|
||||
public async Task<Dictionary<string, string>> GetStaticOverridesAsync(string instanceName)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT attribute_name, override_value
|
||||
FROM static_attribute_overrides
|
||||
WHERE instance_unique_name = @name";
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
|
||||
var results = new Dictionary<string, string>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results[reader.GetString(0)] = reader.GetString(1);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets or updates a single static attribute override for an instance.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">The unique name of the instance.</param>
|
||||
/// <param name="attributeName">The name of the attribute to override.</param>
|
||||
/// <param name="value">The override value for the attribute.</param>
|
||||
public async Task SetStaticOverrideAsync(string instanceName, string attributeName, string value)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO static_attribute_overrides (instance_unique_name, attribute_name, override_value, updated_at)
|
||||
VALUES (@name, @attr, @val, @updatedAt)
|
||||
ON CONFLICT(instance_unique_name, attribute_name) DO UPDATE SET
|
||||
override_value = excluded.override_value,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
command.Parameters.AddWithValue("@attr", attributeName);
|
||||
command.Parameters.AddWithValue("@val", value);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all static attribute overrides for an instance.
|
||||
/// Called on redeployment to reset overrides.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">The unique name of the instance.</param>
|
||||
public async Task ClearStaticOverridesAsync(string instanceName)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM static_attribute_overrides WHERE instance_unique_name = @name";
|
||||
command.Parameters.AddWithValue("@name", instanceName);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
|
||||
}
|
||||
|
||||
// ── WP-33: Shared Script CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a shared script. Uses UPSERT semantics.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the shared script.</param>
|
||||
/// <param name="code">The script code.</param>
|
||||
/// <param name="parameterDefs">JSON representation of parameter definitions, if any.</param>
|
||||
/// <param name="returnDef">JSON representation of the return type definition, if any.</param>
|
||||
public async Task StoreSharedScriptAsync(string name, string code, string? parameterDefs, string? returnDef)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO shared_scripts (name, code, parameter_definitions, return_definition, updated_at)
|
||||
VALUES (@name, @code, @paramDefs, @returnDef, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
code = excluded.code,
|
||||
parameter_definitions = excluded.parameter_definitions,
|
||||
return_definition = excluded.return_definition,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@code", code);
|
||||
command.Parameters.AddWithValue("@paramDefs", (object?)parameterDefs ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@returnDef", (object?)returnDef ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_logger.LogDebug("Stored shared script '{Name}'", name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all stored shared scripts.
|
||||
/// </summary>
|
||||
public async Task<List<StoredSharedScript>> GetAllSharedScriptsAsync()
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name, code, parameter_definitions, return_definition FROM shared_scripts";
|
||||
|
||||
var results = new List<StoredSharedScript>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(new StoredSharedScript
|
||||
{
|
||||
Name = reader.GetString(0),
|
||||
Code = reader.GetString(1),
|
||||
ParameterDefinitions = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
ReturnDefinition = reader.IsDBNull(3) ? null : reader.GetString(3)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── WP-33: External System CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates an external system definition.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the external system.</param>
|
||||
/// <param name="endpointUrl">The REST API endpoint URL.</param>
|
||||
/// <param name="authType">The authentication type (e.g., 'ApiKey', 'BasicAuth').</param>
|
||||
/// <param name="authConfig">Authentication configuration JSON, if applicable.</param>
|
||||
/// <param name="methodDefs">JSON representation of available method definitions, if any.</param>
|
||||
public async Task StoreExternalSystemAsync(
|
||||
string name, string endpointUrl, string authType, string? authConfig, string? methodDefs)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO external_systems (name, endpoint_url, auth_type, auth_configuration, method_definitions, updated_at)
|
||||
VALUES (@name, @url, @authType, @authConfig, @methodDefs, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
endpoint_url = excluded.endpoint_url,
|
||||
auth_type = excluded.auth_type,
|
||||
auth_configuration = excluded.auth_configuration,
|
||||
method_definitions = excluded.method_definitions,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@url", endpointUrl);
|
||||
command.Parameters.AddWithValue("@authType", authType);
|
||||
command.Parameters.AddWithValue("@authConfig", (object?)authConfig ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@methodDefs", (object?)methodDefs ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// ── WP-33: Database Connection CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a database connection definition.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the database connection.</param>
|
||||
/// <param name="connectionString">The database connection string.</param>
|
||||
/// <param name="maxRetries">Maximum number of retry attempts.</param>
|
||||
/// <param name="retryDelay">Delay between retry attempts.</param>
|
||||
public async Task StoreDatabaseConnectionAsync(
|
||||
string name, string connectionString, int maxRetries, TimeSpan retryDelay)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO database_connections (name, connection_string, max_retries, retry_delay_ms, updated_at)
|
||||
VALUES (@name, @connStr, @maxRetries, @retryDelayMs, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
connection_string = excluded.connection_string,
|
||||
max_retries = excluded.max_retries,
|
||||
retry_delay_ms = excluded.retry_delay_ms,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@connStr", connectionString);
|
||||
command.Parameters.AddWithValue("@maxRetries", maxRetries);
|
||||
command.Parameters.AddWithValue("@retryDelayMs", (long)retryDelay.TotalMilliseconds);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// ── WP-33: Notification List CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a notification list.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the notification list.</param>
|
||||
/// <param name="recipientEmails">List of recipient email addresses.</param>
|
||||
public async Task StoreNotificationListAsync(string name, IReadOnlyList<string> recipientEmails)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO notification_lists (name, recipient_emails, updated_at)
|
||||
VALUES (@name, @emails, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
recipient_emails = excluded.recipient_emails,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@emails", System.Text.Json.JsonSerializer.Serialize(recipientEmails));
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// ── WP-33: SMTP Configuration CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates an SMTP configuration.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the SMTP configuration.</param>
|
||||
/// <param name="server">SMTP server hostname.</param>
|
||||
/// <param name="port">SMTP server port.</param>
|
||||
/// <param name="authMode">Authentication mode ('None', 'BasicAuth', 'OAuth2').</param>
|
||||
/// <param name="fromAddress">Email address used as the sender.</param>
|
||||
/// <param name="username">Username for authentication, if applicable.</param>
|
||||
/// <param name="password">Password for authentication, if applicable.</param>
|
||||
/// <param name="oauthConfig">OAuth2 configuration JSON, if applicable.</param>
|
||||
public async Task StoreSmtpConfigurationAsync(
|
||||
string name, string server, int port, string authMode, string fromAddress,
|
||||
string? username, string? password, string? oauthConfig)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
|
||||
VALUES (@name, @server, @port, @authMode, @fromAddress, @username, @password, @oauthConfig, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
server = excluded.server,
|
||||
port = excluded.port,
|
||||
auth_mode = excluded.auth_mode,
|
||||
from_address = excluded.from_address,
|
||||
username = excluded.username,
|
||||
password = excluded.password,
|
||||
oauth_config = excluded.oauth_config,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@server", server);
|
||||
command.Parameters.AddWithValue("@port", port);
|
||||
command.Parameters.AddWithValue("@authMode", authMode);
|
||||
command.Parameters.AddWithValue("@fromAddress", fromAddress);
|
||||
command.Parameters.AddWithValue("@username", (object?)username ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@password", (object?)password ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@oauthConfig", (object?)oauthConfig ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// ── Data Connection Definition CRUD ──
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates a data connection definition (OPC UA endpoint, etc.).
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the data connection.</param>
|
||||
/// <param name="protocol">The protocol type (e.g., 'OpcUa').</param>
|
||||
/// <param name="configJson">Primary configuration as JSON.</param>
|
||||
/// <param name="backupConfigJson">Backup configuration as JSON, if applicable.</param>
|
||||
/// <param name="failoverRetryCount">Number of retries for failover attempts.</param>
|
||||
public async Task StoreDataConnectionDefinitionAsync(
|
||||
string name, string protocol, string? configJson, string? backupConfigJson = null, int failoverRetryCount = 3)
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
INSERT INTO data_connection_definitions (name, protocol, configuration, backup_configuration, failover_retry_count, updated_at)
|
||||
VALUES (@name, @protocol, @config, @backupConfig, @failoverRetryCount, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
protocol = excluded.protocol,
|
||||
configuration = excluded.configuration,
|
||||
backup_configuration = excluded.backup_configuration,
|
||||
failover_retry_count = excluded.failover_retry_count,
|
||||
updated_at = excluded.updated_at";
|
||||
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
command.Parameters.AddWithValue("@protocol", protocol);
|
||||
command.Parameters.AddWithValue("@config", (object?)configJson ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@backupConfig", (object?)backupConfigJson ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@failoverRetryCount", failoverRetryCount);
|
||||
command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
_logger.LogDebug("Stored data connection definition '{Name}' (protocol={Protocol})", name, protocol);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all stored data connection definitions.
|
||||
/// </summary>
|
||||
public async Task<List<StoredDataConnectionDefinition>> GetAllDataConnectionDefinitionsAsync()
|
||||
{
|
||||
await using var connection = new SqliteConnection(_connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name, protocol, configuration, backup_configuration, failover_retry_count FROM data_connection_definitions";
|
||||
|
||||
var results = new List<StoredDataConnectionDefinition>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
results.Add(new StoredDataConnectionDefinition
|
||||
{
|
||||
Name = reader.GetString(0),
|
||||
Protocol = reader.GetString(1),
|
||||
ConfigurationJson = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
BackupConfigurationJson = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
FailoverRetryCount = reader.GetInt32(4)
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a deployed instance configuration as stored in SQLite.
|
||||
/// </summary>
|
||||
public class DeployedInstance
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique name of the instance.
|
||||
/// </summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The deployed configuration as JSON.
|
||||
/// </summary>
|
||||
public string ConfigJson { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The unique deployment identifier.
|
||||
/// </summary>
|
||||
public string DeploymentId { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The configuration revision hash for staleness detection.
|
||||
/// </summary>
|
||||
public string RevisionHash { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Whether the instance is enabled.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; init; }
|
||||
/// <summary>
|
||||
/// The timestamp when the configuration was deployed.
|
||||
/// </summary>
|
||||
public string DeployedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a shared script stored locally in SQLite (WP-33).
|
||||
/// </summary>
|
||||
public class StoredSharedScript
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the shared script.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The script code.
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// JSON representation of parameter definitions, if any.
|
||||
/// </summary>
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
/// <summary>
|
||||
/// JSON representation of the return type definition, if any.
|
||||
/// </summary>
|
||||
public string? ReturnDefinition { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a data connection definition stored locally in SQLite.
|
||||
/// </summary>
|
||||
public class StoredDataConnectionDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the data connection.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The protocol type (e.g., 'OpcUa').
|
||||
/// </summary>
|
||||
public string Protocol { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Primary configuration as JSON.
|
||||
/// </summary>
|
||||
public string? ConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Backup configuration as JSON, if applicable.
|
||||
/// </summary>
|
||||
public string? BackupConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Number of retries for failover attempts.
|
||||
/// </summary>
|
||||
public int FailoverRetryCount { get; init; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Site-side read-only implementation of <see cref="IExternalSystemRepository"/>
|
||||
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
|
||||
/// Write operations throw <see cref="NotSupportedException"/> because site-local
|
||||
/// artifacts are managed exclusively through deployment from Central.
|
||||
/// </summary>
|
||||
public class SiteExternalSystemRepository : IExternalSystemRepository
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new site-side external system repository.
|
||||
/// </summary>
|
||||
/// <param name="storage">Storage service providing database access.</param>
|
||||
public SiteExternalSystemRepository(SiteStorageService storage)
|
||||
{
|
||||
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||
}
|
||||
|
||||
// ── ExternalSystemDefinition (read) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT name, endpoint_url, auth_type, auth_configuration
|
||||
FROM external_systems";
|
||||
|
||||
var results = new List<ExternalSystemDefinition>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(MapExternalSystem(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(
|
||||
int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The SQLite table is keyed by name, not by integer ID.
|
||||
// Scan all rows and match on the synthetic ID derived from the name.
|
||||
var all = await GetAllExternalSystemsAsync(cancellationToken);
|
||||
return all.FirstOrDefault(e => e.Id == id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalSystemDefinition?> GetExternalSystemByNameAsync(
|
||||
string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT name, endpoint_url, auth_type, auth_configuration
|
||||
FROM external_systems
|
||||
WHERE name = @name";
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return null;
|
||||
|
||||
return MapExternalSystem(reader);
|
||||
}
|
||||
|
||||
// ── ExternalSystemMethod (read) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(
|
||||
int externalSystemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Find the parent system to get its name, then parse its method_definitions JSON.
|
||||
var system = await GetExternalSystemByIdAsync(externalSystemId, cancellationToken);
|
||||
if (system is null)
|
||||
return Array.Empty<ExternalSystemMethod>();
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT method_definitions
|
||||
FROM external_systems
|
||||
WHERE name = @name";
|
||||
command.Parameters.AddWithValue("@name", system.Name);
|
||||
|
||||
var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Array.Empty<ExternalSystemMethod>();
|
||||
|
||||
return ParseMethodDefinitions(json, externalSystemId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalSystemMethod?> GetMethodByNameAsync(
|
||||
int externalSystemId, string methodName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var methods = await GetMethodsByExternalSystemIdAsync(externalSystemId, cancellationToken);
|
||||
return methods.FirstOrDefault(
|
||||
m => m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(
|
||||
int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Scan all systems and their methods to find the matching synthetic ID.
|
||||
var systems = await GetAllExternalSystemsAsync(cancellationToken);
|
||||
foreach (var system in systems)
|
||||
{
|
||||
var methods = await GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken);
|
||||
var match = methods.FirstOrDefault(m => m.Id == id);
|
||||
if (match is not null)
|
||||
return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── DatabaseConnectionDefinition (read) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT name, connection_string, max_retries, retry_delay_ms
|
||||
FROM database_connections";
|
||||
|
||||
var results = new List<DatabaseConnectionDefinition>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var def = new DatabaseConnectionDefinition(
|
||||
name: reader.GetString(0),
|
||||
connectionString: reader.GetString(1))
|
||||
{
|
||||
Id = GenerateSyntheticId(reader.GetString(0)),
|
||||
MaxRetries = reader.GetInt32(2),
|
||||
RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3))
|
||||
};
|
||||
results.Add(def);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(
|
||||
int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await GetAllDatabaseConnectionsAsync(cancellationToken);
|
||||
return all.FirstOrDefault(d => d.Id == id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByNameAsync(
|
||||
string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT name, connection_string, max_retries, retry_delay_ms
|
||||
FROM database_connections
|
||||
WHERE name = @name";
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return null;
|
||||
|
||||
return new DatabaseConnectionDefinition(
|
||||
name: reader.GetString(0),
|
||||
connectionString: reader.GetString(1))
|
||||
{
|
||||
Id = GenerateSyntheticId(reader.GetString(0)),
|
||||
MaxRetries = reader.GetInt32(2),
|
||||
RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3))
|
||||
};
|
||||
}
|
||||
|
||||
// ── Write operations (not supported on site) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SQLite connection against the site database via
|
||||
/// <see cref="SiteStorageService.CreateConnection"/> (SiteRuntime-006). The
|
||||
/// connection string is owned by <see cref="SiteStorageService"/>; the repository
|
||||
/// no longer reaches into its private state via reflection.
|
||||
/// </summary>
|
||||
private SqliteConnection CreateConnection() => _storage.CreateConnection();
|
||||
|
||||
private static ExternalSystemDefinition MapExternalSystem(SqliteDataReader reader)
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
return new ExternalSystemDefinition(
|
||||
name: name,
|
||||
endpointUrl: reader.GetString(1),
|
||||
authType: reader.GetString(2))
|
||||
{
|
||||
Id = GenerateSyntheticId(name),
|
||||
AuthConfiguration = reader.IsDBNull(3) ? null : reader.GetString(3)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExternalSystemMethod> ParseMethodDefinitions(
|
||||
string json, int externalSystemId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var methods = JsonSerializer.Deserialize<List<MethodDefinitionDto>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (methods is null)
|
||||
return Array.Empty<ExternalSystemMethod>();
|
||||
|
||||
return methods.Select(m => new ExternalSystemMethod(
|
||||
name: m.Name ?? string.Empty,
|
||||
httpMethod: m.HttpMethod ?? "GET",
|
||||
path: m.Path ?? string.Empty)
|
||||
{
|
||||
Id = GenerateSyntheticId($"{externalSystemId}:{m.Name}"),
|
||||
ExternalSystemDefinitionId = externalSystemId,
|
||||
ParameterDefinitions = m.ParameterDefinitions,
|
||||
ReturnDefinition = m.ReturnDefinition
|
||||
}).ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<ExternalSystemMethod>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a stable positive integer ID from a string name (SiteRuntime-007).
|
||||
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
|
||||
/// which is randomized per process on .NET Core and would therefore change every
|
||||
/// time the process restarts — breaking any caller that stored an ID and later
|
||||
/// looks the entity up by that ID.
|
||||
/// </summary>
|
||||
private static int GenerateSyntheticId(string name) => SyntheticId.From(name);
|
||||
|
||||
/// <summary>
|
||||
/// DTO for deserializing individual method entries from the method_definitions JSON column.
|
||||
/// </summary>
|
||||
private sealed class MethodDefinitionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the external system method.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP method (GET, POST, PUT, DELETE, etc.) for the API call.
|
||||
/// </summary>
|
||||
public string? HttpMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path component of the endpoint URL.
|
||||
/// </summary>
|
||||
public string? Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized parameter definitions for the method.
|
||||
/// </summary>
|
||||
public string? ParameterDefinitions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized return value definition for the method.
|
||||
/// </summary>
|
||||
public string? ReturnDefinition { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Site-side read-only implementation of <see cref="INotificationRepository"/>
|
||||
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
|
||||
/// Write operations throw <see cref="NotSupportedException"/> because site-local
|
||||
/// artifacts are managed exclusively through deployment from Central.
|
||||
/// </summary>
|
||||
public class SiteNotificationRepository : INotificationRepository
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SiteNotificationRepository"/> class.
|
||||
/// </summary>
|
||||
/// <param name="storage">The site storage service for database access.</param>
|
||||
public SiteNotificationRepository(SiteStorageService storage)
|
||||
{
|
||||
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||
}
|
||||
|
||||
// ── NotificationList (read) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationList?> GetListByNameAsync(
|
||||
string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT name, recipient_emails
|
||||
FROM notification_lists
|
||||
WHERE name = @name";
|
||||
command.Parameters.AddWithValue("@name", name);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return null;
|
||||
|
||||
return MapNotificationList(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name, recipient_emails FROM notification_lists";
|
||||
|
||||
var results = new List<NotificationList>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(MapNotificationList(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationList?> GetNotificationListByIdAsync(
|
||||
int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The SQLite table is keyed by name, not by integer ID.
|
||||
// Scan all rows and match on the synthetic ID derived from the name.
|
||||
var all = await GetAllNotificationListsAsync(cancellationToken);
|
||||
return all.FirstOrDefault(l => l.Id == id);
|
||||
}
|
||||
|
||||
// ── NotificationRecipient (read) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(
|
||||
int notificationListId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Find the parent list to get its name, then parse the recipient_emails JSON.
|
||||
var list = await GetNotificationListByIdAsync(notificationListId, cancellationToken);
|
||||
if (list is null)
|
||||
return Array.Empty<NotificationRecipient>();
|
||||
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT recipient_emails
|
||||
FROM notification_lists
|
||||
WHERE name = @name";
|
||||
command.Parameters.AddWithValue("@name", list.Name);
|
||||
|
||||
var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Array.Empty<NotificationRecipient>();
|
||||
|
||||
return ParseRecipientEmails(json, notificationListId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotificationRecipient?> GetRecipientByIdAsync(
|
||||
int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Scan all lists and their recipients to find the matching synthetic ID.
|
||||
var lists = await GetAllNotificationListsAsync(cancellationToken);
|
||||
foreach (var list in lists)
|
||||
{
|
||||
var recipients = await GetRecipientsByListIdAsync(list.Id, cancellationToken);
|
||||
var match = recipients.FirstOrDefault(r => r.Id == id);
|
||||
if (match is not null)
|
||||
return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── SmtpConfiguration (read) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT name, server, port, auth_mode, from_address, username, password, oauth_config
|
||||
FROM smtp_configurations";
|
||||
|
||||
var results = new List<SmtpConfiguration>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(MapSmtpConfiguration(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(
|
||||
int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await GetAllSmtpConfigurationsAsync(cancellationToken);
|
||||
return all.FirstOrDefault(s => s.Id == id);
|
||||
}
|
||||
|
||||
// ── Write operations (not supported on site) ──
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SQLite connection against the site database via
|
||||
/// <see cref="SiteStorageService.CreateConnection"/> (SiteRuntime-006) instead of
|
||||
/// reaching into its private connection-string field via reflection.
|
||||
/// </summary>
|
||||
private SqliteConnection CreateConnection() => _storage.CreateConnection();
|
||||
|
||||
private static NotificationList MapNotificationList(SqliteDataReader reader)
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
var list = new NotificationList(name)
|
||||
{
|
||||
Id = GenerateSyntheticId(name)
|
||||
};
|
||||
|
||||
// Eagerly populate Recipients from the JSON column.
|
||||
var emailsJson = reader.GetString(1);
|
||||
var recipients = ParseRecipientEmails(emailsJson, list.Id);
|
||||
foreach (var r in recipients)
|
||||
{
|
||||
list.Recipients.Add(r);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NotificationRecipient> ParseRecipientEmails(
|
||||
string json, int notificationListId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var emails = JsonSerializer.Deserialize<List<string>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (emails is null)
|
||||
return Array.Empty<NotificationRecipient>();
|
||||
|
||||
return emails.Select(email => new NotificationRecipient(
|
||||
name: email,
|
||||
emailAddress: email)
|
||||
{
|
||||
Id = GenerateSyntheticId($"{notificationListId}:{email}"),
|
||||
NotificationListId = notificationListId
|
||||
}).ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<NotificationRecipient>();
|
||||
}
|
||||
}
|
||||
|
||||
private static SmtpConfiguration MapSmtpConfiguration(SqliteDataReader reader)
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
return new SmtpConfiguration(
|
||||
host: reader.GetString(1),
|
||||
authType: reader.GetString(3),
|
||||
fromAddress: reader.GetString(4))
|
||||
{
|
||||
Id = GenerateSyntheticId(name),
|
||||
Port = reader.GetInt32(2),
|
||||
Credentials = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
TlsMode = reader.IsDBNull(7) ? null : reader.GetString(7)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a stable positive integer ID from a string name (SiteRuntime-007).
|
||||
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
|
||||
/// which is randomized per process on .NET Core and would change every restart.
|
||||
/// </summary>
|
||||
private static int GenerateSyntheticId(string name) => SyntheticId.From(name);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-007: deterministic synthetic-ID generation for site-local artifacts.
|
||||
///
|
||||
/// The site SQLite tables are keyed by name rather than an auto-increment integer, but
|
||||
/// the shared repository contracts (<c>IExternalSystemRepository</c>,
|
||||
/// <c>INotificationRepository</c>) expose integer-keyed lookups. A synthetic integer ID
|
||||
/// is therefore derived from the entity name. It MUST be stable across process restarts
|
||||
/// — <see cref="string.GetHashCode()"/> is randomized per process on .NET Core and so
|
||||
/// cannot be used.
|
||||
/// </summary>
|
||||
internal static class SyntheticId
|
||||
{
|
||||
// FNV-1a 32-bit constants.
|
||||
private const uint FnvOffsetBasis = 2166136261;
|
||||
private const uint FnvPrime = 16777619;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic, process-stable positive 31-bit integer ID for the
|
||||
/// given name using the FNV-1a hash over its UTF-8 bytes.
|
||||
/// </summary>
|
||||
/// <param name="name">The string to hash into a synthetic integer ID.</param>
|
||||
public static int From(string name)
|
||||
{
|
||||
var hash = FnvOffsetBasis;
|
||||
foreach (var b in System.Text.Encoding.UTF8.GetBytes(name))
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= FnvPrime;
|
||||
}
|
||||
|
||||
// Mask to a positive 31-bit value so the ID is always non-negative.
|
||||
return (int)(hash & 0x7FFFFFFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle A: <see cref="DbCommand"/> decorator that emits
|
||||
/// exactly one <c>DbOutbound</c>/<c>DbWrite</c> audit event per execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Vocabulary lock (M4 plan):</b> both writes (Execute / ExecuteScalar) and
|
||||
/// reads (ExecuteReader) emit <see cref="AuditKind.DbWrite"/> on the
|
||||
/// <see cref="AuditChannel.DbOutbound"/> channel. The <c>Extra</c> JSON column
|
||||
/// distinguishes them — <c>{"op":"write","rowsAffected":N}</c> for writes,
|
||||
/// <c>{"op":"read","rowsReturned":N}</c> for reads.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Best-effort emission (alog.md §7):</b> mirrors
|
||||
/// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>'s 3-layer fail-safe.
|
||||
/// The original ADO.NET result (or original exception) flows back to the
|
||||
/// script untouched; audit-build, audit-write, and audit-continuation faults
|
||||
/// are all logged + swallowed. A faulted <see cref="IAuditWriter"/> never
|
||||
/// aborts the SQL call.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AuditingDbCommand : DbCommand
|
||||
{
|
||||
private readonly DbCommand _inner;
|
||||
private readonly IAuditWriter _auditWriter;
|
||||
private readonly string _connectionName;
|
||||
private readonly string _siteId;
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
|
||||
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
|
||||
/// alongside <see cref="_executionId"/> and stamped onto the <c>DbWrite</c>
|
||||
/// audit row.
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private DbConnection? _wrappingConnection;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new auditing database command decorator.
|
||||
/// </summary>
|
||||
/// <param name="inner">The inner database command to wrap and audit.</param>
|
||||
/// <param name="auditWriter">Writer for audit log entries.</param>
|
||||
/// <param name="connectionName">Name of the database connection being used.</param>
|
||||
/// <param name="siteId">Identifier of the site executing the command.</param>
|
||||
/// <param name="instanceName">Unique name of the instance executing the command.</param>
|
||||
/// <param name="sourceScript">Optional name of the source script for audit trail.</param>
|
||||
/// <param name="logger">Logger for diagnostics and warnings.</param>
|
||||
/// <param name="executionId">Unique identifier for this script execution.</param>
|
||||
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
|
||||
// Parameter ordering: executionId sits immediately after the ILogger,
|
||||
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
|
||||
// DatabaseHelper, AuditingDbConnection). parentExecutionId is a trailing
|
||||
// optional param so existing positional callers stay source-compatible.
|
||||
public AuditingDbCommand(
|
||||
DbCommand inner,
|
||||
IAuditWriter auditWriter,
|
||||
string connectionName,
|
||||
string siteId,
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
_connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
|
||||
_siteId = siteId ?? string.Empty;
|
||||
_instanceName = instanceName ?? string.Empty;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_executionId = executionId;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
// -- Forwarded surface ------------------------------------------------
|
||||
|
||||
#pragma warning disable CS8765 // ADO.NET base members carry pre-NRT signatures with permissive nullability
|
||||
/// <inheritdoc />
|
||||
public override string CommandText
|
||||
{
|
||||
get => _inner.CommandText;
|
||||
set => _inner.CommandText = value;
|
||||
}
|
||||
#pragma warning restore CS8765
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int CommandTimeout
|
||||
{
|
||||
get => _inner.CommandTimeout;
|
||||
set => _inner.CommandTimeout = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override CommandType CommandType
|
||||
{
|
||||
get => _inner.CommandType;
|
||||
set => _inner.CommandType = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool DesignTimeVisible
|
||||
{
|
||||
get => _inner.DesignTimeVisible;
|
||||
set => _inner.DesignTimeVisible = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override UpdateRowSource UpdatedRowSource
|
||||
{
|
||||
get => _inner.UpdatedRowSource;
|
||||
set => _inner.UpdatedRowSource = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbConnection? DbConnection
|
||||
{
|
||||
// When the script has wrapped the connection (the normal path through
|
||||
// ScriptRuntimeContext.DatabaseHelper.Connection) we keep returning
|
||||
// the wrapper, but writes from the user go through to the inner
|
||||
// command so the underlying provider keeps its wiring intact.
|
||||
get => _wrappingConnection ?? _inner.Connection;
|
||||
// SiteRuntime-022: unwrap the AuditingDbConnection wrapper via its
|
||||
// own internal Inner accessor instead of reflecting into a private
|
||||
// _inner field. Reflection was the original SiteRuntime-006 anti-
|
||||
// pattern (and is forbidden inside script bodies by the trust
|
||||
// model) — both classes are internal sealed in the same assembly,
|
||||
// so the proper API surface is available without leaking anything
|
||||
// public.
|
||||
set
|
||||
{
|
||||
_wrappingConnection = value;
|
||||
_inner.Connection = value switch
|
||||
{
|
||||
AuditingDbConnection auditing => auditing.Inner,
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbParameterCollection DbParameterCollection => _inner.Parameters;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbTransaction? DbTransaction
|
||||
{
|
||||
get => _inner.Transaction;
|
||||
set => _inner.Transaction = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Cancel() => _inner.Cancel();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Prepare() => _inner.Prepare();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbParameter CreateDbParameter() => _inner.CreateParameter();
|
||||
|
||||
// -- Audited execution surface ---------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int ExecuteNonQuery()
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
int rows = 0;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
rows = _inner.ExecuteNonQuery();
|
||||
return rows;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? rows : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? ExecuteScalar()
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
object? scalar = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
scalar = _inner.ExecuteScalar();
|
||||
return scalar;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// ExecuteScalar is classified as "write" per the M4 vocabulary
|
||||
// lock — it's a single-value execution; rowsAffected mirrors the
|
||||
// inner command's value if exposed (DbCommand has no RecordsAffected
|
||||
// property, so we report -1 when the provider didn't surface it).
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? -1 : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
int rows = 0;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
rows = await _inner.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? rows : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<object?> ExecuteScalarAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
object? scalar = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
scalar = await _inner.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return scalar;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? -1 : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
DbDataReader? reader = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
reader = _inner.ExecuteReader(behavior);
|
||||
return new AuditingDbDataReader(
|
||||
reader,
|
||||
onClose: rows => EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: rows,
|
||||
thrown: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
// Emit the failure row immediately — no reader to wait on.
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DbDataReader> ExecuteDbDataReaderAsync(
|
||||
CommandBehavior behavior, CancellationToken cancellationToken)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
DbDataReader? reader = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
reader = await _inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false);
|
||||
return new AuditingDbDataReader(
|
||||
reader,
|
||||
onClose: rows => EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: rows,
|
||||
thrown: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
// -- Emission ---------------------------------------------------------
|
||||
|
||||
private static int ElapsedMs(long startTicks) =>
|
||||
(int)((Stopwatch.GetTimestamp() - startTicks) * 1000d / Stopwatch.Frequency);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort emission of one <c>DbOutbound</c>/<c>DbWrite</c> audit row.
|
||||
/// Mirrors the M2 Bundle F <c>EmitCallAudit</c> 3-layer fail-safe pattern.
|
||||
/// </summary>
|
||||
private void EmitAudit(
|
||||
DateTime occurredAtUtc,
|
||||
int durationMs,
|
||||
string op,
|
||||
int? rowsAffected,
|
||||
int? rowsReturned,
|
||||
Exception? thrown)
|
||||
{
|
||||
AuditEvent evt;
|
||||
try
|
||||
{
|
||||
evt = BuildAuditEvent(occurredAtUtc, durationMs, op, rowsAffected, rowsReturned, thrown);
|
||||
}
|
||||
catch (Exception buildEx)
|
||||
{
|
||||
// Defensive: building the event from already-validated fields
|
||||
// shouldn't throw, but the alog.md §7 contract requires we never
|
||||
// propagate to the user-facing action regardless.
|
||||
_logger.LogWarning(buildEx,
|
||||
"Failed to build Audit Log #23 event for {Connection} (op={Op}) — skipping emission",
|
||||
_connectionName, op);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var writeTask = _auditWriter.WriteAsync(evt, CancellationToken.None);
|
||||
if (!writeTask.IsCompleted)
|
||||
{
|
||||
writeTask.ContinueWith(
|
||||
t => _logger.LogWarning(t.Exception,
|
||||
"Audit Log #23 write failed for EventId {EventId} ({Connection} op={Op})",
|
||||
evt.EventId, _connectionName, op),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
else if (writeTask.IsFaulted)
|
||||
{
|
||||
_logger.LogWarning(writeTask.Exception,
|
||||
"Audit Log #23 write failed for EventId {EventId} ({Connection} op={Op})",
|
||||
evt.EventId, _connectionName, op);
|
||||
}
|
||||
}
|
||||
catch (Exception writeEx)
|
||||
{
|
||||
// Synchronous throw from WriteAsync before its own try/catch.
|
||||
// Swallow + log per alog.md §7.
|
||||
_logger.LogWarning(writeEx,
|
||||
"Audit Log #23 write threw synchronously for EventId {EventId} ({Connection} op={Op})",
|
||||
evt.EventId, _connectionName, op);
|
||||
}
|
||||
}
|
||||
|
||||
private AuditEvent BuildAuditEvent(
|
||||
DateTime occurredAtUtc,
|
||||
int durationMs,
|
||||
string op,
|
||||
int? rowsAffected,
|
||||
int? rowsReturned,
|
||||
Exception? thrown)
|
||||
{
|
||||
var status = thrown == null ? AuditStatus.Delivered : AuditStatus.Failed;
|
||||
|
||||
// Target = "<connectionName>.<first 60 chars of SQL>" so the audit
|
||||
// row carries a human-recognisable handle without dragging the full
|
||||
// (potentially huge) statement into the index column. The full
|
||||
// statement + parameter values live in RequestSummary.
|
||||
string target = _connectionName;
|
||||
var sqlSnippet = _inner.CommandText ?? string.Empty;
|
||||
if (sqlSnippet.Length > 0)
|
||||
{
|
||||
var snippet = sqlSnippet.Length > 60
|
||||
? sqlSnippet[..60]
|
||||
: sqlSnippet;
|
||||
target = $"{_connectionName}.{snippet}";
|
||||
}
|
||||
|
||||
// RequestSummary captures the SQL statement + parameter values by
|
||||
// default per the alog.md M4 acceptance criteria (M5 will add
|
||||
// per-connection redaction opt-in).
|
||||
string? requestSummary = BuildRequestSummary();
|
||||
|
||||
// Extra carries the op discriminator + row count per the vocabulary
|
||||
// lock. Build as a small hand-rolled JSON object to avoid pulling
|
||||
// in System.Text.Json on the hot path.
|
||||
string extra = op == "write"
|
||||
? $"{{\"op\":\"write\",\"rowsAffected\":{(rowsAffected ?? 0)}}}"
|
||||
: $"{{\"op\":\"read\",\"rowsReturned\":{(rowsReturned ?? 0)}}}";
|
||||
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.DbOutbound,
|
||||
Kind = AuditKind.DbWrite,
|
||||
// Audit Log #23: a sync one-shot DB write has no operation
|
||||
// lifecycle, so CorrelationId is null. ExecutionId carries the
|
||||
// per-execution id so this row shares an id with the other sync
|
||||
// trust-boundary rows from the same script run.
|
||||
CorrelationId = null,
|
||||
ExecutionId = _executionId,
|
||||
// Audit Log #23 (ParentExecutionId): the spawning execution's id;
|
||||
// null for non-routed runs.
|
||||
ParentExecutionId = _parentExecutionId,
|
||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||
SourceInstanceId = _instanceName,
|
||||
SourceScript = _sourceScript,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the actor is
|
||||
// the calling script. Null when no single script owns the call
|
||||
// (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = target,
|
||||
Status = status,
|
||||
HttpStatus = null,
|
||||
DurationMs = durationMs,
|
||||
ErrorMessage = thrown?.Message,
|
||||
ErrorDetail = thrown?.ToString(),
|
||||
RequestSummary = requestSummary,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = extra,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose a JSON request summary capturing the SQL statement and
|
||||
/// parameter values. Parameter values are captured by default per the
|
||||
/// M4 acceptance criteria — redaction is opt-in and deferred to M5.
|
||||
/// </summary>
|
||||
private string? BuildRequestSummary()
|
||||
{
|
||||
var sql = _inner.CommandText;
|
||||
if (string.IsNullOrEmpty(sql))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hand-roll the JSON so we don't pull in System.Text.Json for a
|
||||
// shape this small. Values are stringified with ToString() — fully
|
||||
// structured serialisation arrives with the redaction work in M5.
|
||||
var sb = new System.Text.StringBuilder(sql.Length + 64);
|
||||
sb.Append("{\"sql\":");
|
||||
AppendJsonString(sb, sql);
|
||||
|
||||
if (_inner.Parameters.Count > 0)
|
||||
{
|
||||
sb.Append(",\"parameters\":{");
|
||||
var first = true;
|
||||
foreach (DbParameter p in _inner.Parameters)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
AppendJsonString(sb, p.ParameterName);
|
||||
sb.Append(':');
|
||||
if (p.Value is null || p.Value is DBNull)
|
||||
{
|
||||
sb.Append("null");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendJsonString(sb, p.Value.ToString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendJsonString(System.Text.StringBuilder sb, string value)
|
||||
{
|
||||
sb.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\b': sb.Append("\\b"); break;
|
||||
case '\f': sb.Append("\\f"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (ch < 0x20)
|
||||
{
|
||||
sb.Append("\\u").Append(((int)ch).ToString("x4"));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle A: thin decorator over the
|
||||
/// <see cref="DbConnection"/> returned by
|
||||
/// <see cref="ScriptRuntimeContext.DatabaseHelper.Connection"/>. The decorator
|
||||
/// itself does no audit work — it simply intercepts
|
||||
/// <see cref="CreateDbCommand"/> so the <see cref="DbCommand"/> handed back to
|
||||
/// the script is wrapped in an <see cref="AuditingDbCommand"/> that emits one
|
||||
/// <c>DbOutbound</c>/<c>DbWrite</c> audit row per execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All other <see cref="DbConnection"/> members forward to the inner connection
|
||||
/// unchanged so the script keeps full ADO.NET semantics (transactions, state
|
||||
/// transitions, server-version queries, etc.). Disposing the wrapper disposes
|
||||
/// the inner connection — the caller is still responsible for disposal per
|
||||
/// the <see cref="IDatabaseGateway"/> contract.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The audit-write failure contract (alog.md §7) is honoured at the
|
||||
/// <see cref="AuditingDbCommand"/> layer — see that class for the 3-layer
|
||||
/// fail-safe pattern (build, write, observe).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AuditingDbConnection : DbConnection
|
||||
{
|
||||
private readonly DbConnection _inner;
|
||||
private readonly IAuditWriter _auditWriter;
|
||||
private readonly string _connectionName;
|
||||
private readonly string _siteId;
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
|
||||
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
|
||||
/// alongside <see cref="_executionId"/> into the
|
||||
/// <see cref="AuditingDbCommand"/> so its <c>DbWrite</c> row stamps it.
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new auditing database connection decorator.
|
||||
/// </summary>
|
||||
/// <param name="inner">The inner database connection to wrap and audit commands through.</param>
|
||||
/// <param name="auditWriter">Writer for audit log entries.</param>
|
||||
/// <param name="connectionName">Name of the database connection being used.</param>
|
||||
/// <param name="siteId">Identifier of the site executing commands.</param>
|
||||
/// <param name="instanceName">Unique name of the instance executing commands.</param>
|
||||
/// <param name="sourceScript">Optional name of the source script for audit trail.</param>
|
||||
/// <param name="logger">Logger for diagnostics and warnings.</param>
|
||||
/// <param name="executionId">Unique identifier for this script execution.</param>
|
||||
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
|
||||
// Parameter ordering: executionId sits immediately after the ILogger,
|
||||
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
|
||||
// DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing
|
||||
// optional param so existing positional callers stay source-compatible.
|
||||
public AuditingDbConnection(
|
||||
DbConnection inner,
|
||||
IAuditWriter auditWriter,
|
||||
string connectionName,
|
||||
string siteId,
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
_connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
|
||||
_siteId = siteId ?? string.Empty;
|
||||
_instanceName = instanceName ?? string.Empty;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_executionId = executionId;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-022: exposes the wrapped <see cref="DbConnection"/> to the
|
||||
/// sibling <see cref="AuditingDbCommand"/> in the same assembly, so the
|
||||
/// command's <c>DbConnection</c> setter can unwrap an
|
||||
/// <see cref="AuditingDbConnection"/> without reflecting into the
|
||||
/// private <c>_inner</c> field. Both classes are <c>internal sealed</c>
|
||||
/// in this assembly, so the accessor stays out of the public API and
|
||||
/// matches the SiteRuntime-006 precedent of preferring proper API surface
|
||||
/// over <see cref="System.Reflection"/>.
|
||||
/// </summary>
|
||||
internal DbConnection Inner => _inner;
|
||||
|
||||
/// <inheritdoc />
|
||||
// ConnectionString is settable on DbConnection — forward both halves.
|
||||
public override string ConnectionString
|
||||
{
|
||||
// Some providers throw on get when the connection hasn't been opened
|
||||
// with a string set explicitly. The wrapper has no opinion — forward.
|
||||
#pragma warning disable CS8765 // nullability of overridden member parameter — base setter accepts null in practice
|
||||
get => _inner.ConnectionString;
|
||||
set => _inner.ConnectionString = value;
|
||||
#pragma warning restore CS8765
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Database => _inner.Database;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string DataSource => _inner.DataSource;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ServerVersion => _inner.ServerVersion;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ConnectionState State => _inner.State;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void ChangeDatabase(string databaseName) => _inner.ChangeDatabase(databaseName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Close() => _inner.Close();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Open() => _inner.Open();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task OpenAsync(CancellationToken cancellationToken) => _inner.OpenAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
|
||||
=> _inner.BeginTransaction(isolationLevel);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbCommand CreateDbCommand()
|
||||
{
|
||||
var innerCmd = _inner.CreateCommand();
|
||||
// Hand the script an auditing wrapper. The wrapper preserves the
|
||||
// inner command's identity for parameters / type maps via delegation.
|
||||
return new AuditingDbCommand(
|
||||
innerCmd,
|
||||
_auditWriter,
|
||||
_connectionName,
|
||||
_siteId,
|
||||
_instanceName,
|
||||
_sourceScript,
|
||||
_logger,
|
||||
_executionId,
|
||||
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
|
||||
// threaded alongside _executionId. Null for non-routed runs.
|
||||
_parentExecutionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
// DbConnection.DisposeAsync is virtual; calling base would run the
|
||||
// synchronous Dispose path. Forward to the inner connection
|
||||
// asynchronously and short-circuit the base.
|
||||
var task = _inner.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle A: <see cref="DbDataReader"/> decorator that
|
||||
/// counts the number of rows read by the script and fires a single audit
|
||||
/// emission callback when the reader closes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The wrapping reader counts each successful <see cref="Read"/> /
|
||||
/// <see cref="ReadAsync(CancellationToken)"/> and invokes <c>onClose</c>
|
||||
/// exactly once — on <see cref="Close"/>, <see cref="CloseAsync"/>, or
|
||||
/// disposal — with the running tally. This lets
|
||||
/// <see cref="AuditingDbCommand"/> emit one
|
||||
/// <c>DbOutbound</c>/<c>DbWrite</c> row per <c>ExecuteReader</c> with
|
||||
/// <c>Extra.rowsReturned</c> populated, matching the M4 vocabulary lock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Multiple result sets via <see cref="NextResult"/> are folded into a single
|
||||
/// <c>rowsReturned</c> tally — the script sees one audit row per
|
||||
/// <c>ExecuteReader</c> call, not per result set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AuditingDbDataReader : DbDataReader
|
||||
{
|
||||
private readonly DbDataReader _inner;
|
||||
private readonly Action<int> _onClose;
|
||||
private int _rowsReturned;
|
||||
private bool _closed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuditingDbDataReader"/> class, wrapping a data reader to count rows read.
|
||||
/// </summary>
|
||||
/// <param name="inner">The underlying DbDataReader to wrap and audit.</param>
|
||||
/// <param name="onClose">Callback invoked once when the reader closes, receiving the total rows read.</param>
|
||||
public AuditingDbDataReader(DbDataReader inner, Action<int> onClose)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_onClose = onClose ?? throw new ArgumentNullException(nameof(onClose));
|
||||
}
|
||||
|
||||
// -- Row-count interception ------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Read()
|
||||
{
|
||||
var more = _inner.Read();
|
||||
if (more) _rowsReturned++;
|
||||
return more;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<bool> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var more = await _inner.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (more) _rowsReturned++;
|
||||
return more;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Close()
|
||||
{
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { _inner.Close(); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task CloseAsync()
|
||||
{
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { await _inner.CloseAsync().ConfigureAwait(false); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// DbDataReader.Dispose calls Close on most providers, but we
|
||||
// guard with _closed to ensure onClose fires exactly once.
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { _inner.Dispose(); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
else
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { await _inner.DisposeAsync().ConfigureAwait(false); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
else
|
||||
{
|
||||
await _inner.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void SafeFireOnClose()
|
||||
{
|
||||
// The onClose callback runs the audit emission, which is itself
|
||||
// best-effort and swallows internally — but defend the reader's own
|
||||
// close path anyway so an audit fault never propagates out of
|
||||
// Close/Dispose.
|
||||
try { _onClose(_rowsReturned); }
|
||||
catch { /* audit emission is best-effort by contract */ }
|
||||
}
|
||||
|
||||
// -- Forwarded surface ------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object this[int ordinal] => _inner[ordinal];
|
||||
/// <inheritdoc />
|
||||
public override object this[string name] => _inner[name];
|
||||
/// <inheritdoc />
|
||||
public override int Depth => _inner.Depth;
|
||||
/// <inheritdoc />
|
||||
public override int FieldCount => _inner.FieldCount;
|
||||
/// <inheritdoc />
|
||||
public override bool HasRows => _inner.HasRows;
|
||||
/// <inheritdoc />
|
||||
public override bool IsClosed => _inner.IsClosed;
|
||||
/// <inheritdoc />
|
||||
public override int RecordsAffected => _inner.RecordsAffected;
|
||||
/// <inheritdoc />
|
||||
public override int VisibleFieldCount => _inner.VisibleFieldCount;
|
||||
/// <inheritdoc />
|
||||
public override bool GetBoolean(int ordinal) => _inner.GetBoolean(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override byte GetByte(int ordinal) => _inner.GetByte(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
|
||||
=> _inner.GetBytes(ordinal, dataOffset, buffer, bufferOffset, length);
|
||||
/// <inheritdoc />
|
||||
public override char GetChar(int ordinal) => _inner.GetChar(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
|
||||
=> _inner.GetChars(ordinal, dataOffset, buffer, bufferOffset, length);
|
||||
/// <inheritdoc />
|
||||
public override string GetDataTypeName(int ordinal) => _inner.GetDataTypeName(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override DateTime GetDateTime(int ordinal) => _inner.GetDateTime(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override decimal GetDecimal(int ordinal) => _inner.GetDecimal(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override double GetDouble(int ordinal) => _inner.GetDouble(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override IEnumerator GetEnumerator() => ((IEnumerable)_inner).GetEnumerator();
|
||||
/// <inheritdoc />
|
||||
public override Type GetFieldType(int ordinal) => _inner.GetFieldType(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override float GetFloat(int ordinal) => _inner.GetFloat(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override Guid GetGuid(int ordinal) => _inner.GetGuid(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override short GetInt16(int ordinal) => _inner.GetInt16(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override int GetInt32(int ordinal) => _inner.GetInt32(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override long GetInt64(int ordinal) => _inner.GetInt64(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override string GetName(int ordinal) => _inner.GetName(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override int GetOrdinal(string name) => _inner.GetOrdinal(name);
|
||||
/// <inheritdoc />
|
||||
public override string GetString(int ordinal) => _inner.GetString(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override object GetValue(int ordinal) => _inner.GetValue(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override int GetValues(object[] values) => _inner.GetValues(values);
|
||||
/// <inheritdoc />
|
||||
public override bool IsDBNull(int ordinal) => _inner.IsDBNull(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override bool NextResult() => _inner.NextResult();
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> NextResultAsync(CancellationToken cancellationToken) => _inner.NextResultAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Scope-aware view onto the instance's attributes, anchored at a path prefix.
|
||||
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
|
||||
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
|
||||
///
|
||||
/// <para>
|
||||
/// Thread-model note (SiteRuntime-012): the indexer get/set block synchronously
|
||||
/// on the Instance Actor Ask (and, for data-connected attributes, the DCL
|
||||
/// round-trip). This is safe because script bodies execute on the dedicated
|
||||
/// <see cref="ScriptExecutionScheduler"/> threads (SiteRuntime-009), not the
|
||||
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
|
||||
/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async
|
||||
/// variants (<see cref="GetAsync"/>/<see cref="SetAsync"/>) are still preferred
|
||||
/// where the script can await, as they avoid holding a dedicated thread idle for
|
||||
/// the duration of each round-trip.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AttributeAccessor
|
||||
{
|
||||
private readonly ScriptRuntimeContext _ctx;
|
||||
|
||||
/// <summary>Canonical-name prefix, e.g. "" for root or "TempSensor" for a composition.</summary>
|
||||
public string ScopePrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AttributeAccessor with the specified context and prefix.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="prefix">The canonical-name prefix for attribute resolution.</param>
|
||||
public AttributeAccessor(ScriptRuntimeContext ctx, string prefix)
|
||||
{
|
||||
_ctx = ctx;
|
||||
ScopePrefix = prefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a key to its full canonical name by applying the scope prefix.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to resolve.</param>
|
||||
public string Resolve(string key) =>
|
||||
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an attribute value synchronously by canonical name.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key.</param>
|
||||
public object? this[string key]
|
||||
{
|
||||
// Both reads and writes block on the actor Ask; the write also blocks
|
||||
// on the DCL round-trip for data-connected attributes. The async
|
||||
// variants (GetAsync/SetAsync) are preferred where awaiting is possible.
|
||||
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
|
||||
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attribute value asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key.</param>
|
||||
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute value asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key.</param>
|
||||
/// <param name="value">The value to set.</param>
|
||||
public Task SetAsync(string key, object? value)
|
||||
=> _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A view of one composition at a path. Exposes its attributes via
|
||||
/// <see cref="AttributeAccessor"/> and an invokable <c>CallScript</c>.
|
||||
/// </summary>
|
||||
public class CompositionAccessor
|
||||
{
|
||||
private readonly ScriptRuntimeContext _ctx;
|
||||
|
||||
/// <summary>Canonical-name path this composition is rooted at.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Accessor for attributes within this composition.
|
||||
/// </summary>
|
||||
public AttributeAccessor Attributes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the CompositionAccessor with the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="path">The canonical-name path for the composition.</param>
|
||||
public CompositionAccessor(ScriptRuntimeContext ctx, string path)
|
||||
{
|
||||
_ctx = ctx;
|
||||
Path = path;
|
||||
Attributes = new AttributeAccessor(ctx, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a script name to its full canonical name by applying the composition path.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The script name to resolve.</param>
|
||||
public string ResolveScript(string scriptName) =>
|
||||
Path.Length == 0 ? scriptName : Path + "." + scriptName;
|
||||
|
||||
/// <summary>
|
||||
/// Calls a script within this composition.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The name of the script to call.</param>
|
||||
/// <param name="parameters">Optional parameters to pass to the script.</param>
|
||||
public Task<object?> CallScript(string scriptName, object? parameters = null)
|
||||
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary-style accessor for the script's child compositions. Indexing
|
||||
/// returns a <see cref="CompositionAccessor"/> rooted at the child's path.
|
||||
/// </summary>
|
||||
public class ChildrenAccessor
|
||||
{
|
||||
private readonly ScriptRuntimeContext _ctx;
|
||||
private readonly string _selfPath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ChildrenAccessor with the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="selfPath">The canonical-name path of the parent composition.</param>
|
||||
public ChildrenAccessor(ScriptRuntimeContext ctx, string selfPath)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_selfPath = selfPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a composition accessor for a child by name.
|
||||
/// </summary>
|
||||
/// <param name="compositionName">The name of the child composition.</param>
|
||||
public CompositionAccessor this[string compositionName]
|
||||
{
|
||||
get
|
||||
{
|
||||
var path = _selfPath.Length == 0
|
||||
? compositionName
|
||||
: _selfPath + "." + compositionName;
|
||||
return new CompositionAccessor(_ctx, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ScopeAccessorFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an AttributeAccessor for the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="selfPath">The canonical-name path.</param>
|
||||
public static AttributeAccessor AttributesFor(ScriptRuntimeContext ctx, string selfPath)
|
||||
=> new(ctx, selfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ChildrenAccessor for the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="selfPath">The canonical-name path.</param>
|
||||
public static ChildrenAccessor ChildrenFor(ScriptRuntimeContext ctx, string selfPath)
|
||||
=> new(ctx, selfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CompositionAccessor for the specified context and path, or null if no parent exists.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="parentPath">The parent path, or null if no parent.</param>
|
||||
public static CompositionAccessor? ParentFor(ScriptRuntimeContext ctx, string? parentPath)
|
||||
=> parentPath == null ? null : new CompositionAccessor(ctx, parentPath);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-19: Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
|
||||
/// Forbidden APIs: System.IO, Process, Threading (except async/await), Reflection,
|
||||
/// System.Net.Sockets, System.Net.Http.
|
||||
/// </summary>
|
||||
public class ScriptCompilationService
|
||||
{
|
||||
private readonly ILogger<ScriptCompilationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Forbidden API roots. Each entry is matched as a prefix against both the resolved
|
||||
/// symbol's containing namespace and its fully-qualified containing type name, so an
|
||||
/// entry may name a whole namespace ("System.IO") or a single type
|
||||
/// ("System.Diagnostics.Process").
|
||||
/// </summary>
|
||||
private static readonly string[] ForbiddenNamespaces =
|
||||
[
|
||||
"System.IO",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading",
|
||||
"System.Reflection",
|
||||
"System.Net.Sockets",
|
||||
"System.Net.Http"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Specific namespaces/types allowed even though they sit under a forbidden root.
|
||||
/// async/await and cancellation tokens are OK despite System.Threading being blocked.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExceptions =
|
||||
[
|
||||
"System.Threading.Tasks",
|
||||
"System.Threading.CancellationToken",
|
||||
"System.Threading.CancellationTokenSource"
|
||||
];
|
||||
|
||||
/// <summary>Initializes a new instance of the ScriptCompilationService class.</summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-011: validates that the script does not reference forbidden APIs.
|
||||
///
|
||||
/// Validation is performed with Roslyn semantic analysis rather than a raw substring
|
||||
/// scan of the source text. The script is parsed and a semantic model is built; every
|
||||
/// identifier, type reference, member access, and object creation is resolved to its
|
||||
/// symbol and the symbol's containing namespace is checked against the forbidden list.
|
||||
///
|
||||
/// This is reliable in both directions a textual scan was not:
|
||||
/// - it catches forbidden types regardless of how they are written (<c>global::</c>
|
||||
/// prefixes, aliases, transitively-imported namespaces) because it inspects the
|
||||
/// resolved symbol, not the spelling;
|
||||
/// - it does not raise false positives for the namespace string appearing in a
|
||||
/// comment, a string literal, or an unrelated identifier.
|
||||
///
|
||||
/// Returns a list of violation messages, empty if clean.
|
||||
/// </summary>
|
||||
/// <param name="code">The script code to validate.</param>
|
||||
public IReadOnlyList<string> ValidateTrustModel(string code)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(
|
||||
code, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
|
||||
var compilation = CSharpCompilation.CreateScriptCompilation(
|
||||
"TrustValidation",
|
||||
tree,
|
||||
ScriptReferences,
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
var root = tree.GetRoot();
|
||||
|
||||
// Deduplicate so a forbidden symbol used many times is reported once but
|
||||
// distinct forbidden symbols are all reported.
|
||||
var violations = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var node in root.DescendantNodes())
|
||||
{
|
||||
// Only inspect nodes that name a type or member; skip declarations,
|
||||
// string literals and comments entirely. Member-access and qualified-name
|
||||
// parents are evaluated as a whole, so their nested name parts are skipped.
|
||||
if (node is not (SimpleNameSyntax or MemberAccessExpressionSyntax
|
||||
or QualifiedNameSyntax or ObjectCreationExpressionSyntax))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = model.GetSymbolInfo(node);
|
||||
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||
|
||||
// The set of fully-qualified scopes this reference touches: the resolved
|
||||
// symbol's containing namespace and type, or — when the symbol could not
|
||||
// be resolved (a type from an unreferenced assembly) — the syntactic
|
||||
// fully-qualified name written in source as a safe fallback.
|
||||
var scopes = symbol != null
|
||||
? GetSymbolScopes(symbol)
|
||||
: GetSyntacticScopes(node);
|
||||
if (scopes.Count == 0)
|
||||
continue;
|
||||
|
||||
var forbidden = ForbiddenNamespaces.FirstOrDefault(
|
||||
f => scopes.Any(s => IsUnderScope(s, f)));
|
||||
if (forbidden == null)
|
||||
continue;
|
||||
|
||||
// Allow specific exception namespaces/types (async/await, cancellation).
|
||||
if (scopes.Any(s => AllowedExceptions.Any(a => IsUnderScope(s, a))))
|
||||
continue;
|
||||
|
||||
var name = symbol?.Name ?? node.ToString();
|
||||
violations.Add($"Forbidden API reference: '{forbidden}' ({scopes[0]}.{name})");
|
||||
}
|
||||
|
||||
return violations.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the fully-qualified scopes a resolved symbol belongs to — its containing
|
||||
/// namespace and, for a type or member, the fully-qualified containing type. A bare
|
||||
/// namespace symbol is intentionally ignored: a namespace name on its own performs
|
||||
/// no action; harm requires referencing a type or a member.
|
||||
/// </summary>
|
||||
private static List<string> GetSymbolScopes(ISymbol symbol)
|
||||
{
|
||||
var scopes = new List<string>();
|
||||
|
||||
switch (symbol)
|
||||
{
|
||||
case INamespaceSymbol:
|
||||
// A namespace reference alone is harmless — skip it. (This avoids a
|
||||
// false positive on the "System.Threading" qualifier of the allowed
|
||||
// "System.Threading.Tasks.Task".)
|
||||
break;
|
||||
case ITypeSymbol typeSymbol:
|
||||
scopes.Add(typeSymbol.ToDisplayString());
|
||||
if (typeSymbol.ContainingNamespace is { IsGlobalNamespace: false } typeNs)
|
||||
scopes.Add(typeNs.ToDisplayString());
|
||||
break;
|
||||
default:
|
||||
if (symbol.ContainingType != null)
|
||||
{
|
||||
scopes.Add(symbol.ContainingType.ToDisplayString());
|
||||
if (symbol.ContainingType.ContainingNamespace is { IsGlobalNamespace: false } memberNs)
|
||||
scopes.Add(memberNs.ToDisplayString());
|
||||
}
|
||||
else if (symbol.ContainingNamespace is { IsGlobalNamespace: false } ns)
|
||||
{
|
||||
scopes.Add(ns.ToDisplayString());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback used when a name could not be resolved to a symbol (e.g. a type from an
|
||||
/// assembly the script is not allowed to reference). The fully-qualified name as
|
||||
/// written in source is used directly — a script that names
|
||||
/// <c>System.Net.Http.HttpClient</c> is still rejected even though that assembly is
|
||||
/// deliberately absent from the script's metadata references.
|
||||
/// </summary>
|
||||
private static List<string> GetSyntacticScopes(SyntaxNode node)
|
||||
{
|
||||
// A dotted name written in source is itself the fully-qualified scope. Only
|
||||
// consider names that actually contain a dot — bare local identifiers cannot
|
||||
// reach a forbidden namespace.
|
||||
var text = node switch
|
||||
{
|
||||
QualifiedNameSyntax q => q.ToString(),
|
||||
MemberAccessExpressionSyntax m => m.ToString(),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
// Strip whitespace/newlines that a multi-line member-access chain may contain.
|
||||
text = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
|
||||
return string.IsNullOrEmpty(text) || !text.Contains('.')
|
||||
? []
|
||||
: [text];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="actual"/> is exactly, or nested within,
|
||||
/// <paramref name="root"/> (e.g. "System.IO.Compression" is under "System.IO",
|
||||
/// "System.Diagnostics.Process" is under "System.Diagnostics.Process").
|
||||
/// </summary>
|
||||
private static bool IsUnderScope(string actual, string root)
|
||||
=> actual.Equals(root, StringComparison.Ordinal)
|
||||
|| actual.StartsWith(root + ".", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Assemblies referenced by compiled scripts. Shared between the Roslyn scripting
|
||||
/// options and the semantic-analysis compilation built for trust validation
|
||||
/// (SiteRuntime-011), so the validator resolves symbols against exactly the same
|
||||
/// metadata the script is compiled against.
|
||||
/// </summary>
|
||||
private static readonly System.Reflection.Assembly[] ScriptAssemblies =
|
||||
[
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Math).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||
typeof(Commons.Types.DynamicJsonElement).Assembly
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Metadata references for the trust-validation semantic compilation.
|
||||
/// </summary>
|
||||
private static readonly MetadataReference[] ScriptReferences =
|
||||
ScriptAssemblies
|
||||
.Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Shared Roslyn scripting options (references + imports) used by both full
|
||||
/// script compilation and trigger-expression compilation.
|
||||
/// </summary>
|
||||
private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default
|
||||
.WithReferences(ScriptAssemblies)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||
/// and parameters dictionary, and returns an object? result.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The name of the script.</param>
|
||||
/// <param name="code">The script code to compile.</param>
|
||||
public ScriptCompilationResult Compile(string scriptName, string code)
|
||||
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a bare C# boolean trigger expression against the restricted
|
||||
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
|
||||
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
|
||||
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
|
||||
/// options and forbidden-API trust validation as <see cref="Compile"/>.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the trigger expression.</param>
|
||||
/// <param name="expression">The trigger expression to compile.</param>
|
||||
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
|
||||
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
|
||||
|
||||
/// <summary>
|
||||
/// Shared compilation path: validates the trust model, builds the script
|
||||
/// against the given globals type, and returns the compiled result.
|
||||
/// </summary>
|
||||
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
|
||||
{
|
||||
// Validate trust model
|
||||
var violations = ValidateTrustModel(code);
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} failed trust validation: {Violations}",
|
||||
name, string.Join("; ", violations));
|
||||
return ScriptCompilationResult.Failed(violations);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var script = CSharpScript.Create<object?>(
|
||||
code,
|
||||
BuildScriptOptions(),
|
||||
globalsType: globalsType);
|
||||
|
||||
var diagnostics = script.Compile();
|
||||
var errors = diagnostics
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.Select(d => d.GetMessage())
|
||||
.ToList();
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} compilation failed: {Errors}",
|
||||
name, string.Join("; ", errors));
|
||||
return ScriptCompilationResult.Failed(errors);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Script {Script} compiled successfully", name);
|
||||
return ScriptCompilationResult.Succeeded(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error compiling script {Script}", name);
|
||||
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of script compilation, containing either the compiled script or error messages.
|
||||
/// </summary>
|
||||
public class ScriptCompilationResult
|
||||
{
|
||||
/// <summary>Indicates whether compilation succeeded.</summary>
|
||||
public bool IsSuccess { get; }
|
||||
/// <summary>The compiled script, or null if compilation failed.</summary>
|
||||
public Script<object?>? CompiledScript { get; }
|
||||
/// <summary>List of error messages, empty if compilation succeeded.</summary>
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
|
||||
{
|
||||
IsSuccess = success;
|
||||
CompiledScript = script;
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
/// <summary>Creates a successful compilation result.</summary>
|
||||
/// <param name="script">The compiled script.</param>
|
||||
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
|
||||
new(true, script, []);
|
||||
|
||||
/// <summary>Creates a failed compilation result.</summary>
|
||||
/// <param name="errors">List of error messages.</param>
|
||||
public static ScriptCompilationResult Failed(IReadOnlyList<string> errors) =>
|
||||
new(false, null, errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected
|
||||
/// as the "Instance" global, and parameters are available via "Parameters".
|
||||
/// </summary>
|
||||
public class ScriptGlobals
|
||||
{
|
||||
/// <summary>The script runtime context providing access to instance state.</summary>
|
||||
public ScriptRuntimeContext Instance { get; set; } = null!;
|
||||
/// <summary>Script parameters passed by the caller.</summary>
|
||||
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
||||
/// <summary>Cancellation token for script execution.</summary>
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context when this script is invoked as an on-trigger handler.
|
||||
/// Null for instance scripts, shared scripts, and inbound-API-routed
|
||||
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
|
||||
/// (HiLo only), Priority, and per-band Message to branch routing logic.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where this script sits in the composition tree. Defaults to root for
|
||||
/// scripts on top-level templates; a flattened composed script gets
|
||||
/// SelfPath = "TempSensor" (etc.) and a ParentPath set to one level up.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.ScriptScope Scope { get; set; } =
|
||||
Commons.Types.Scripts.ScriptScope.Root;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
|
||||
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Database access for scripts (delegates to Instance.Database).
|
||||
/// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Notify access for scripts (delegates to Instance.Notify).
|
||||
/// Usage: Notify.To("listName").Send("subject", "message")
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Scripts access for shared script calls (delegates to Instance.Scripts).
|
||||
/// Usage: Scripts.CallShared("scriptName", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Read/write the current template's attributes by name. Resolves to the
|
||||
/// canonical name for the script's scope, so a script on a composed
|
||||
/// TempSensor reads its own Temperature via <c>Attributes["Temperature"]</c>.
|
||||
/// </summary>
|
||||
public AttributeAccessor Attributes => new(Instance, Scope.SelfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Indexed access to child compositions.
|
||||
/// <c>Children["TempSensor"].Attributes["Temperature"]</c> reads the
|
||||
/// composed child's attribute. <c>Children["TempSensor"].CallScript("Sample")</c>
|
||||
/// invokes a script on the child.
|
||||
/// </summary>
|
||||
public ChildrenAccessor Children => new(Instance, Scope.SelfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Parent composition (null when this script is on a root-level template).
|
||||
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
||||
/// <c>Parent.CallScript("Trip")</c> invokes a parent script.
|
||||
/// </summary>
|
||||
public CompositionAccessor? Parent =>
|
||||
Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-009: a dedicated, bounded <see cref="TaskScheduler"/> for running script
|
||||
/// and alarm on-trigger bodies.
|
||||
///
|
||||
/// Script bodies may perform synchronous blocking I/O (a database connection, a
|
||||
/// synchronous external-system call). Running them on the shared .NET
|
||||
/// <see cref="ThreadPool"/> lets a burst of blocking scripts starve the pool and stall
|
||||
/// unrelated Akka dispatchers and HTTP request handling. This scheduler owns a fixed set
|
||||
/// of dedicated threads, so script blocking is contained to those threads and cannot
|
||||
/// exhaust the global pool.
|
||||
///
|
||||
/// The scheduler is process-wide (one set of threads for all instances) and is sized
|
||||
/// from <see cref="SiteRuntimeOptions"/> the first time it is configured.
|
||||
/// </summary>
|
||||
public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
||||
{
|
||||
private readonly BlockingCollection<Task> _queue = new();
|
||||
private readonly List<Thread> _threads;
|
||||
private int _disposed;
|
||||
|
||||
private static volatile ScriptExecutionScheduler? _shared;
|
||||
private static readonly object SharedLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// The process-wide script-execution scheduler. Lazily created on first use with the
|
||||
/// thread count from <see cref="SiteRuntimeOptions.ScriptExecutionThreadCount"/>; the
|
||||
/// first caller wins, subsequent calls reuse the existing instance.
|
||||
/// </summary>
|
||||
/// <param name="options">Site runtime options supplying the thread count for the scheduler.</param>
|
||||
public static ScriptExecutionScheduler Shared(SiteRuntimeOptions options)
|
||||
{
|
||||
if (_shared != null)
|
||||
return _shared;
|
||||
|
||||
lock (SharedLock)
|
||||
{
|
||||
return _shared ??= new ScriptExecutionScheduler(options.ScriptExecutionThreadCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scheduler backed by <paramref name="threadCount"/> dedicated threads.
|
||||
/// </summary>
|
||||
/// <param name="threadCount">Number of dedicated worker threads to create.</param>
|
||||
public ScriptExecutionScheduler(int threadCount)
|
||||
{
|
||||
if (threadCount < 1)
|
||||
threadCount = 1;
|
||||
|
||||
_threads = new List<Thread>(threadCount);
|
||||
for (var i = 0; i < threadCount; i++)
|
||||
{
|
||||
var thread = new Thread(WorkerLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"script-execution-{i}"
|
||||
};
|
||||
_threads.Add(thread);
|
||||
thread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int MaximumConcurrencyLevel => _threads.Count;
|
||||
|
||||
private void WorkerLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var task in _queue.GetConsumingEnumerable())
|
||||
{
|
||||
TryExecuteTask(task);
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Scheduler disposed — worker exits.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void QueueTask(Task task) => _queue.Add(task);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
|
||||
{
|
||||
// Only inline if we are already on one of this scheduler's worker threads,
|
||||
// so script work never escapes onto a thread-pool thread.
|
||||
if (Thread.CurrentThread.Name?.StartsWith("script-execution-", StringComparison.Ordinal) != true)
|
||||
return false;
|
||||
|
||||
return TryExecuteTask(task);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<Task> GetScheduledTasks() => _queue.ToArray();
|
||||
|
||||
/// <summary>Signals the worker threads to stop and waits for them to drain, then disposes the queue.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
return;
|
||||
|
||||
_queue.CompleteAdding();
|
||||
foreach (var thread in _threads)
|
||||
thread.Join(TimeSpan.FromSeconds(5));
|
||||
_queue.Dispose();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Shared Script Library — stores compiled shared script delegates in memory.
|
||||
/// Shared scripts are compiled when received from central and executed inline
|
||||
/// (direct method call, not actor message). NOT available on central.
|
||||
/// WP-33: Recompiled on update when new artifacts arrive.
|
||||
/// </summary>
|
||||
public class SharedScriptLibrary
|
||||
{
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly ILogger<SharedScriptLibrary> _logger;
|
||||
private readonly Dictionary<string, Script<object?>> _compiledScripts = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SharedScriptLibrary"/> with the compilation service and logger.
|
||||
/// </summary>
|
||||
/// <param name="compilationService">Service used to compile Roslyn scripts.</param>
|
||||
/// <param name="logger">Logger for compilation warnings and diagnostics.</param>
|
||||
public SharedScriptLibrary(
|
||||
ScriptCompilationService compilationService,
|
||||
ILogger<SharedScriptLibrary> logger)
|
||||
{
|
||||
_compilationService = compilationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and registers a shared script. Replaces any existing script with the same name.
|
||||
/// Returns true if compilation succeeded, false otherwise.
|
||||
/// </summary>
|
||||
/// <param name="name">Unique name for the shared script.</param>
|
||||
/// <param name="code">C# source code of the shared script.</param>
|
||||
public bool CompileAndRegister(string name, string code)
|
||||
{
|
||||
var result = _compilationService.Compile(name, code);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Shared script '{Name}' failed to compile: {Errors}",
|
||||
name, string.Join("; ", result.Errors));
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_compiledScripts[name] = result.CompiledScript!;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Shared script '{Name}' compiled and registered", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a shared script from the library.
|
||||
/// </summary>
|
||||
/// <param name="name">Name of the shared script to remove.</param>
|
||||
public bool Remove(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.Remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a shared script inline with the given runtime context.
|
||||
/// This is a direct method call, not an actor message — executes on the calling thread.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">Name of the shared script to execute.</param>
|
||||
/// <param name="context">Runtime context providing instance state and services to the script globals.</param>
|
||||
/// <param name="parameters">Optional input parameters passed to the script.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the execution.</param>
|
||||
public async Task<object?> ExecuteAsync(
|
||||
string scriptName,
|
||||
ScriptRuntimeContext context,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Script<object?> script;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_compiledScripts.TryGetValue(scriptName, out script!))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared script '{scriptName}' not found in library.");
|
||||
}
|
||||
}
|
||||
|
||||
var globals = new ScriptGlobals
|
||||
{
|
||||
Instance = context,
|
||||
Parameters = new ScriptParameters(parameters ?? new Dictionary<string, object?>()),
|
||||
CancellationToken = cancellationToken
|
||||
};
|
||||
|
||||
var state = await script.RunAsync(globals, cancellationToken);
|
||||
return state.ReturnValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the names of all currently registered shared scripts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetRegisteredScriptNames()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.Keys.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a script with the given name is registered.
|
||||
/// </summary>
|
||||
/// <param name="name">Name of the shared script to look up.</param>
|
||||
public bool Contains(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.ContainsKey(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only globals a trigger expression is compiled against. Exposes only
|
||||
/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask,
|
||||
/// no side-effecting APIs. A missing attribute key reads as <c>null</c> and
|
||||
/// never throws.
|
||||
///
|
||||
/// Canonical attribute keys are dotted (e.g. "TempSensor.Reading"); the prefix
|
||||
/// logic here mirrors <see cref="AttributeAccessor.Resolve"/>.
|
||||
/// </summary>
|
||||
public sealed class TriggerExpressionGlobals
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts the <c>"expression"</c> field from an Expression-trigger config
|
||||
/// JSON document. Returns <c>null</c> for a missing, blank, or malformed
|
||||
/// config — the single parsing idiom shared by InstanceActor, ScriptActor,
|
||||
/// and AlarmActor.
|
||||
/// </summary>
|
||||
/// <param name="triggerConfigJson">JSON string of the trigger configuration, or null.</param>
|
||||
public static string? ExtractExpression(string? triggerConfigJson)
|
||||
{
|
||||
if (string.IsNullOrEmpty(triggerConfigJson)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
var expr = doc.RootElement.TryGetProperty("expression", out var e)
|
||||
? e.GetString()
|
||||
: null;
|
||||
return string.IsNullOrWhiteSpace(expr) ? null : expr;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly IReadOnlyDictionary<string, object?> _snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the globals with a flat attribute snapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Flat dictionary of canonical attribute key to current value.</param>
|
||||
public TriggerExpressionGlobals(IReadOnlyDictionary<string, object?> snapshot)
|
||||
=> _snapshot = snapshot;
|
||||
|
||||
/// <summary>Attributes in the expression's own scope (root prefix).</summary>
|
||||
public ReadOnlyAttributes Attributes => new(_snapshot, "");
|
||||
|
||||
/// <summary>Indexed access to child compositions' attributes.</summary>
|
||||
public ReadOnlyChildren Children => new(_snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Parent composition (null at root). Set by the caller for derived/composed
|
||||
/// scopes; the runtime actors evaluate at root scope, so this stays null.
|
||||
/// </summary>
|
||||
public ReadOnlyComposition? Parent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Read-only attribute view anchored at a canonical-name prefix. Indexing
|
||||
/// resolves to the canonical key ("" → key, "TempSensor" → "TempSensor.key").
|
||||
/// </summary>
|
||||
public sealed class ReadOnlyAttributes
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||
private readonly string _prefix;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a read-only attribute view anchored at the given prefix.
|
||||
/// </summary>
|
||||
/// <param name="s">The backing attribute snapshot.</param>
|
||||
/// <param name="prefix">Canonical name prefix; empty string for root scope.</param>
|
||||
public ReadOnlyAttributes(IReadOnlyDictionary<string, object?> s, string prefix)
|
||||
{
|
||||
_s = s;
|
||||
_prefix = prefix;
|
||||
}
|
||||
|
||||
/// <summary>Returns the attribute value for <paramref name="key"/>, or null if absent.</summary>
|
||||
/// <param name="key">The attribute key, relative to the prefix.</param>
|
||||
public object? this[string key] =>
|
||||
_s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null;
|
||||
}
|
||||
|
||||
/// <summary>A read-only view of one composition at a canonical-name path.</summary>
|
||||
public sealed class ReadOnlyComposition
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||
private readonly string _path;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a read-only composition view anchored at a canonical path.
|
||||
/// </summary>
|
||||
/// <param name="s">The backing attribute snapshot.</param>
|
||||
/// <param name="path">Canonical path prefix for this composition's attributes.</param>
|
||||
public ReadOnlyComposition(IReadOnlyDictionary<string, object?> s, string path)
|
||||
{
|
||||
_s = s;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
/// <summary>Read-only attribute view scoped to this composition's canonical path.</summary>
|
||||
public ReadOnlyAttributes Attributes => new(_s, _path);
|
||||
}
|
||||
|
||||
/// <summary>Dictionary-style accessor for child compositions.</summary>
|
||||
public sealed class ReadOnlyChildren
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a children accessor over the supplied attribute snapshot.
|
||||
/// </summary>
|
||||
/// <param name="s">The backing attribute snapshot.</param>
|
||||
public ReadOnlyChildren(IReadOnlyDictionary<string, object?> s) => _s = s;
|
||||
|
||||
/// <summary>Returns a read-only composition view for the named child composition.</summary>
|
||||
/// <param name="compositionName">The canonical composition name.</param>
|
||||
public ReadOnlyComposition this[string compositionName] => new(_s, compositionName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers Site Runtime services including SiteStorageService for SQLite persistence.
|
||||
/// The caller must register an <see cref="ISiteStorageConnectionProvider"/> or call the
|
||||
/// overload with an explicit connection string.
|
||||
/// </summary>
|
||||
/// <param name="services">The DI service collection to register services into.</param>
|
||||
public static IServiceCollection AddSiteRuntime(this IServiceCollection services)
|
||||
{
|
||||
// SiteStorageService is registered by the Host using AddSiteRuntime(connectionString)
|
||||
// This overload is for backward compatibility / skeleton placeholder
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Site Runtime services with an explicit SQLite connection string.
|
||||
/// </summary>
|
||||
/// <param name="services">The DI service collection to register services into.</param>
|
||||
/// <param name="siteDbConnectionString">The SQLite connection string for the site local storage database.</param>
|
||||
public static IServiceCollection AddSiteRuntime(this IServiceCollection services, string siteDbConnectionString)
|
||||
{
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var logger = sp.GetRequiredService<ILogger<SiteStorageService>>();
|
||||
return new SiteStorageService(siteDbConnectionString, logger);
|
||||
});
|
||||
|
||||
services.AddHostedService<SiteStorageInitializer>();
|
||||
|
||||
// WP-19: Script compilation service
|
||||
services.AddSingleton<ScriptCompilationService>();
|
||||
|
||||
// WP-17: Shared script library
|
||||
services.AddSingleton<SharedScriptLibrary>();
|
||||
|
||||
// WP-23: Site stream manager — registered as singleton and exposed as ISiteStreamSubscriber
|
||||
// so the gRPC server can subscribe relay actors to instance events.
|
||||
// ActorSystem is injected later via Initialize() after AkkaHostedService starts.
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SiteRuntimeOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILogger<SiteStreamManager>>();
|
||||
return new SiteStreamManager(options, logger);
|
||||
});
|
||||
services.AddSingleton<ISiteStreamSubscriber>(sp => sp.GetRequiredService<SiteStreamManager>());
|
||||
|
||||
// Site-local repository implementations backed by SQLite
|
||||
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, SiteNotificationRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers any additional DI services needed by the Site Runtime Akka actors.</summary>
|
||||
/// <param name="services">The DI service collection to register services into.</param>
|
||||
public static IServiceCollection AddSiteRuntimeActors(this IServiceCollection services)
|
||||
{
|
||||
// Actor registration is handled by AkkaHostedService.RegisterSiteActors()
|
||||
// which creates the DeploymentManager singleton and proxy
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Site Runtime component.
|
||||
/// Bound from ScadaBridge:SiteRuntime configuration section.
|
||||
/// </summary>
|
||||
public class SiteRuntimeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of Instance Actors to create per batch during staggered startup.
|
||||
/// Default: 20.
|
||||
/// </summary>
|
||||
public int StartupBatchSize { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Delay in milliseconds between startup batches to prevent reconnection storms.
|
||||
/// Default: 100ms.
|
||||
/// </summary>
|
||||
public int StartupBatchDelayMs { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum call depth for recursive script calls (CallScript/CallShared).
|
||||
/// Default: 10.
|
||||
/// </summary>
|
||||
public int MaxScriptCallDepth { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Default script execution timeout in seconds.
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public int ScriptExecutionTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscriber buffer size for the site-wide Akka stream.
|
||||
/// Slow subscribers drop oldest messages when buffer is full.
|
||||
/// Default: 1000.
|
||||
/// </summary>
|
||||
public int StreamBufferSize { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-009: number of dedicated threads in the script-execution scheduler.
|
||||
/// Script and alarm on-trigger bodies run on these threads instead of the shared
|
||||
/// .NET thread pool, so blocking script I/O cannot starve the global pool.
|
||||
/// Default: 8.
|
||||
/// </summary>
|
||||
public int ScriptExecutionThreadCount { get; set; } = 8;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Akka;
|
||||
using Akka.Actor;
|
||||
using Akka.Streams;
|
||||
using Akka.Streams.Dsl;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value
|
||||
/// and alarm state changes. Instance Actors publish events via fire-and-forget Tell.
|
||||
/// A BroadcastHub fans events out to per-subscriber graphs, each filtered by
|
||||
/// instance name and bounded by a drop-oldest buffer.
|
||||
///
|
||||
/// Filterable by instance name for debug view (WP-25).
|
||||
/// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors
|
||||
/// to instance events without referencing SiteRuntime directly.
|
||||
/// </summary>
|
||||
public class SiteStreamManager : ISiteStreamSubscriber
|
||||
{
|
||||
private ActorSystem? _system;
|
||||
private IMaterializer? _materializer;
|
||||
private readonly int _bufferSize;
|
||||
private readonly ILogger<SiteStreamManager> _logger;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private IActorRef? _sourceActor;
|
||||
private Source<ISiteStreamEvent, NotUsed>? _hubSource;
|
||||
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
|
||||
|
||||
/// <summary>Initializes the stream manager with configuration and logger; the Akka stream is not started until <see cref="Initialize"/> is called.</summary>
|
||||
/// <param name="options">Site runtime options providing the stream buffer size.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public SiteStreamManager(
|
||||
SiteRuntimeOptions options,
|
||||
ILogger<SiteStreamManager> logger)
|
||||
{
|
||||
_bufferSize = options.StreamBufferSize;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the broadcast stream. Must be called after ActorSystem is ready.
|
||||
/// The ActorSystem is passed here rather than via the constructor so that
|
||||
/// SiteStreamManager can be created by DI before the actor system exists.
|
||||
/// </summary>
|
||||
/// <param name="system">The running Akka <see cref="ActorSystem"/> used to materialize the broadcast stream.</param>
|
||||
public void Initialize(ActorSystem system)
|
||||
{
|
||||
_system = system;
|
||||
_materializer = _system.Materializer();
|
||||
|
||||
var (sourceActor, hubSource) = Source.ActorRef<ISiteStreamEvent>(
|
||||
_bufferSize,
|
||||
OverflowStrategy.DropHead)
|
||||
.ToMaterialized(
|
||||
BroadcastHub.Sink<ISiteStreamEvent>(bufferSize: 256),
|
||||
Keep.Both)
|
||||
.Run(_materializer);
|
||||
|
||||
_sourceActor = sourceActor;
|
||||
_hubSource = hubSource;
|
||||
|
||||
_logger.LogInformation(
|
||||
"SiteStreamManager initialized with publish buffer size {BufferSize}", _bufferSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an attribute value change to the broadcast hub.
|
||||
/// Fire-and-forget — never blocks the calling actor.
|
||||
/// </summary>
|
||||
/// <param name="changed">The attribute value change event to publish.</param>
|
||||
public void PublishAttributeValueChanged(AttributeValueChanged changed)
|
||||
{
|
||||
_sourceActor?.Tell(changed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an alarm state change to the broadcast hub.
|
||||
/// Fire-and-forget — never blocks the calling actor.
|
||||
/// </summary>
|
||||
/// <param name="changed">The alarm state change event to publish.</param>
|
||||
public void PublishAlarmStateChanged(AlarmStateChanged changed)
|
||||
{
|
||||
_sourceActor?.Tell(changed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Subscribe(string instanceName, IActorRef subscriber)
|
||||
{
|
||||
if (_hubSource is null || _materializer is null)
|
||||
throw new InvalidOperationException("SiteStreamManager.Initialize must be called before Subscribe");
|
||||
|
||||
var subscriptionId = Guid.NewGuid().ToString();
|
||||
var capturedInstance = instanceName;
|
||||
var capturedSubscriber = subscriber;
|
||||
|
||||
var killSwitch = _hubSource
|
||||
.Where(ev => ev.InstanceUniqueName == capturedInstance)
|
||||
.Buffer(_bufferSize, OverflowStrategy.DropHead)
|
||||
.ViaMaterialized(KillSwitches.Single<ISiteStreamEvent>(), Keep.Right)
|
||||
.To(Sink.ForEach<ISiteStreamEvent>(ev => capturedSubscriber.Tell(ev)))
|
||||
.Run(_materializer);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_subscriptions[subscriptionId] = new SubscriptionInfo(
|
||||
instanceName, subscriber, killSwitch, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Subscriber {SubscriptionId} registered for instance {Instance}",
|
||||
subscriptionId, instanceName);
|
||||
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-25: Unsubscribe from instance events. Shuts down the per-subscriber
|
||||
/// stream graph via its KillSwitch.
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">The subscription ID returned by <see cref="Subscribe"/>.</param>
|
||||
/// <returns><c>true</c> if the subscription was found and removed; <c>false</c> if it was already gone.</returns>
|
||||
public bool Unsubscribe(string subscriptionId)
|
||||
{
|
||||
SubscriptionInfo? info;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_subscriptions.Remove(subscriptionId, out info))
|
||||
return false;
|
||||
}
|
||||
|
||||
info.KillSwitch.Shutdown();
|
||||
_logger.LogDebug("Subscriber {SubscriptionId} removed", subscriptionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveSubscriber(IActorRef subscriber)
|
||||
{
|
||||
List<SubscriptionInfo> toShutdown;
|
||||
lock (_lock)
|
||||
{
|
||||
var matched = _subscriptions
|
||||
.Where(kvp => kvp.Value.Subscriber.Equals(subscriber))
|
||||
.ToList();
|
||||
foreach (var kvp in matched)
|
||||
_subscriptions.Remove(kvp.Key);
|
||||
toShutdown = matched.Select(kvp => kvp.Value).ToList();
|
||||
}
|
||||
|
||||
foreach (var info in toShutdown)
|
||||
info.KillSwitch.Shutdown();
|
||||
|
||||
if (toShutdown.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Removed {Count} subscriptions for disconnected subscriber", toShutdown.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of active subscriptions (for diagnostics/testing).
|
||||
/// </summary>
|
||||
public int SubscriptionCount
|
||||
{
|
||||
get { lock (_lock) { return _subscriptions.Count; } }
|
||||
}
|
||||
|
||||
private record SubscriptionInfo(
|
||||
string InstanceName,
|
||||
IActorRef Subscriber,
|
||||
IKillSwitch KillSwitch,
|
||||
DateTimeOffset SubscribedAt);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
||||
|
||||
/// <summary>
|
||||
/// Options for <see cref="OperationTrackingStore"/> — site-local cached-call
|
||||
/// tracking SQLite store (Audit Log #23 / M3).
|
||||
/// </summary>
|
||||
public class OperationTrackingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Full ADO.NET connection string for the SQLite database (e.g.
|
||||
/// <c>"Data Source=site-tracking.db"</c>). Tests use the
|
||||
/// <c>Mode=Memory;Cache=Shared</c> form to keep the database in-memory.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = "Data Source=site-tracking.db";
|
||||
|
||||
/// <summary>
|
||||
/// Retention window for terminal tracking rows. The default purge cadence
|
||||
/// (driven by the host) deletes terminal rows older than this many days.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 7;
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
|
||||
|
||||
/// <summary>
|
||||
/// Site-local SQLite source-of-truth for cached-operation tracking — the row
|
||||
/// that <c>Tracking.Status(TrackedOperationId)</c> reads (Audit Log #23 / M3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One row per <see cref="TrackedOperationId"/>; lifecycle is
|
||||
/// <c>Submitted → Retrying → Delivered / Parked / Failed / Discarded</c>; terminal
|
||||
/// rows are purged after the configured retention window
|
||||
/// (<see cref="OperationTrackingOptions.RetentionDays"/>). Volume is bounded —
|
||||
/// only cached calls produce rows, and only a handful of lifecycle events per
|
||||
/// call — so we keep the implementation deliberately simple: a single owned
|
||||
/// <see cref="SqliteConnection"/> serialised behind a <see cref="SemaphoreSlim"/>
|
||||
/// (one async writer at a time). This is the pattern the M3 brief calls out as
|
||||
/// "cleaner than the M2 Channel<T> pipeline given the volume"; the M2
|
||||
/// audit-writer's batched-channel design is reserved for the high-volume audit
|
||||
/// hot-path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All mutations are idempotent / monotonic: <see cref="RecordEnqueueAsync"/> is
|
||||
/// <c>INSERT OR IGNORE</c>, <see cref="RecordAttemptAsync"/> filters out terminal
|
||||
/// rows in the <c>WHERE</c> clause, and <see cref="RecordTerminalAsync"/> only
|
||||
/// fires on rows that haven't terminated yet (first-write-wins). This makes the
|
||||
/// store safe under the at-least-once semantics of the site→central telemetry
|
||||
/// path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, IDisposable
|
||||
{
|
||||
// SiteRuntime-024: writer state — one owned SqliteConnection serialised behind
|
||||
// _writeGate. Readers do NOT share this connection or gate; see GetStatusAsync.
|
||||
private readonly SqliteConnection _writeConnection;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<OperationTrackingStore> _logger;
|
||||
|
||||
// SiteRuntime-024: dispose-once state shared by the sync Dispose and async
|
||||
// DisposeAsync paths. Interlocked.Exchange is the race-safe primitive here —
|
||||
// a plain bool can be flipped twice if Dispose() and DisposeAsync() are
|
||||
// invoked concurrently (e.g. host shutdown bridging both). 0 = live,
|
||||
// 1 = disposed. Read by other methods via Volatile.Read after the gate is
|
||||
// taken; they raise ObjectDisposedException when set.
|
||||
private int _disposeState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the tracking store, opens the SQLite connection, and applies the schema.
|
||||
/// </summary>
|
||||
/// <param name="options">Tracking store configuration (connection string, retention window).</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
public OperationTrackingStore(
|
||||
IOptions<OperationTrackingOptions> options,
|
||||
ILogger<OperationTrackingStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_logger = logger;
|
||||
_connectionString = options.Value.ConnectionString;
|
||||
_writeConnection = new SqliteConnection(_connectionString);
|
||||
_writeConnection.Open();
|
||||
InitializeSchema();
|
||||
}
|
||||
|
||||
private void InitializeSchema()
|
||||
{
|
||||
using var cmd = _writeConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS OperationTracking (
|
||||
TrackedOperationId TEXT NOT NULL PRIMARY KEY,
|
||||
Kind TEXT NOT NULL,
|
||||
TargetSummary TEXT NULL,
|
||||
Status TEXT NOT NULL,
|
||||
RetryCount INTEGER NOT NULL DEFAULT 0,
|
||||
LastError TEXT NULL,
|
||||
HttpStatus INTEGER NULL,
|
||||
CreatedAtUtc TEXT NOT NULL,
|
||||
UpdatedAtUtc TEXT NOT NULL,
|
||||
TerminalAtUtc TEXT NULL,
|
||||
SourceInstanceId TEXT NULL,
|
||||
SourceScript TEXT NULL,
|
||||
SourceNode TEXT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS IX_OperationTracking_Status_Updated
|
||||
ON OperationTracking (Status, UpdatedAtUtc);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
// SourceNode stamping: additively add the SourceNode column.
|
||||
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an
|
||||
// OperationTracking table that already exists from a pre-SourceNode
|
||||
// build, so a tracking.db created by an older build needs the column
|
||||
// ALTER-ed in. The file is durable across restart/failover by design
|
||||
// (retention window default 7 days), so without this step every
|
||||
// RecordEnqueueAsync on an upgraded deployment would bind $sourceNode
|
||||
// against a missing column and the write would fail.
|
||||
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
|
||||
// probed first and the ALTER skipped when already there. The column is
|
||||
// nullable with no default, so any row written before this migration
|
||||
// reads back SourceNode = null (back-compat).
|
||||
//
|
||||
// NOTE: This is the FIRST idempotent column-upgrade in
|
||||
// OperationTrackingStore — prior schema changes pre-dated any
|
||||
// production rollout and relied solely on CREATE TABLE IF NOT EXISTS.
|
||||
// The helper mirrors the SqliteAuditWriter precedent.
|
||||
AddColumnIfMissing("SourceNode", "TEXT NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additively adds a column to <c>OperationTracking</c> only when it is not
|
||||
/// already present. SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>, so the
|
||||
/// schema is probed via <c>PRAGMA table_info</c> first. Idempotent — safe
|
||||
/// to run on every <see cref="InitializeSchema"/>. Mirrors the
|
||||
/// <c>SqliteAuditWriter.AddColumnIfMissing</c> precedent.
|
||||
/// </summary>
|
||||
private void AddColumnIfMissing(string columnName, string columnDefinition)
|
||||
{
|
||||
using var probe = _writeConnection.CreateCommand();
|
||||
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('OperationTracking') WHERE name = $name";
|
||||
probe.Parameters.AddWithValue("$name", columnName);
|
||||
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var alter = _writeConnection.CreateCommand();
|
||||
// Column name + definition are caller-controlled constants, never user
|
||||
// input — safe to interpolate (parameters are not permitted in DDL).
|
||||
alter.CommandText = $"ALTER TABLE OperationTracking ADD COLUMN {columnName} {columnDefinition}";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RecordEnqueueAsync(
|
||||
TrackedOperationId id,
|
||||
string kind,
|
||||
string? targetSummary,
|
||||
string? sourceInstanceId,
|
||||
string? sourceScript,
|
||||
string? sourceNode,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(kind);
|
||||
|
||||
await _writeGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
|
||||
|
||||
var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
|
||||
|
||||
using var cmd = _writeConnection.CreateCommand();
|
||||
// INSERT OR IGNORE: duplicate ids are no-ops (first-write-wins) —
|
||||
// matches the at-least-once semantics the site emits under.
|
||||
cmd.CommandText = """
|
||||
INSERT OR IGNORE INTO OperationTracking (
|
||||
TrackedOperationId, Kind, TargetSummary, Status,
|
||||
RetryCount, LastError, HttpStatus,
|
||||
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
|
||||
SourceInstanceId, SourceScript, SourceNode
|
||||
) VALUES (
|
||||
$id, $kind, $targetSummary, $status,
|
||||
0, NULL, NULL,
|
||||
$now, $now, NULL,
|
||||
$sourceInstanceId, $sourceScript, $sourceNode
|
||||
);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id.ToString());
|
||||
cmd.Parameters.AddWithValue("$kind", kind);
|
||||
cmd.Parameters.AddWithValue("$targetSummary", (object?)targetSummary ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$status", "Submitted");
|
||||
cmd.Parameters.AddWithValue("$now", now);
|
||||
cmd.Parameters.AddWithValue("$sourceInstanceId", (object?)sourceInstanceId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$sourceScript", (object?)sourceScript ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$sourceNode", (object?)sourceNode ?? DBNull.Value);
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RecordAttemptAsync(
|
||||
TrackedOperationId id,
|
||||
string status,
|
||||
int retryCount,
|
||||
string? lastError,
|
||||
int? httpStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
|
||||
await _writeGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
|
||||
|
||||
var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
|
||||
|
||||
using var cmd = _writeConnection.CreateCommand();
|
||||
// Terminal rows are immutable — the WHERE clause filters them out so
|
||||
// late-arriving attempt telemetry never overwrites a resolved row.
|
||||
cmd.CommandText = """
|
||||
UPDATE OperationTracking
|
||||
SET Status = $status,
|
||||
RetryCount = $retryCount,
|
||||
LastError = $lastError,
|
||||
HttpStatus = $httpStatus,
|
||||
UpdatedAtUtc = $now
|
||||
WHERE TrackedOperationId = $id
|
||||
AND TerminalAtUtc IS NULL;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id.ToString());
|
||||
cmd.Parameters.AddWithValue("$status", status);
|
||||
cmd.Parameters.AddWithValue("$retryCount", retryCount);
|
||||
cmd.Parameters.AddWithValue("$lastError", (object?)lastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$httpStatus", (object?)httpStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$now", now);
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RecordTerminalAsync(
|
||||
TrackedOperationId id,
|
||||
string status,
|
||||
string? lastError,
|
||||
int? httpStatus,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(status);
|
||||
|
||||
await _writeGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
|
||||
|
||||
var now = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
|
||||
|
||||
using var cmd = _writeConnection.CreateCommand();
|
||||
// First-write-wins on the terminal flip: only update rows that
|
||||
// haven't already terminated.
|
||||
cmd.CommandText = """
|
||||
UPDATE OperationTracking
|
||||
SET Status = $status,
|
||||
LastError = $lastError,
|
||||
HttpStatus = $httpStatus,
|
||||
UpdatedAtUtc = $now,
|
||||
TerminalAtUtc = $now
|
||||
WHERE TrackedOperationId = $id
|
||||
AND TerminalAtUtc IS NULL;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id.ToString());
|
||||
cmd.Parameters.AddWithValue("$status", status);
|
||||
cmd.Parameters.AddWithValue("$lastError", (object?)lastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$httpStatus", (object?)httpStatus ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$now", now);
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<TrackingStatusSnapshot?> GetStatusAsync(
|
||||
TrackedOperationId id,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
|
||||
|
||||
// SiteRuntime-024: reads open a fresh, ungated SqliteConnection so a
|
||||
// long-running write doesn't block status queries. The connection
|
||||
// string is shared with the writer; SQLite handles cross-connection
|
||||
// isolation natively (a reader sees a consistent snapshot via the
|
||||
// shared cache lock for in-memory DBs, or a WAL snapshot for file DBs).
|
||||
// Mirrors the SiteStorageService precedent.
|
||||
await using var readConnection = new SqliteConnection(_connectionString);
|
||||
await readConnection.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = readConnection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT TrackedOperationId, Kind, TargetSummary, Status,
|
||||
RetryCount, LastError, HttpStatus,
|
||||
CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc,
|
||||
SourceInstanceId, SourceScript, SourceNode
|
||||
FROM OperationTracking
|
||||
WHERE TrackedOperationId = $id;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id.ToString());
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TrackingStatusSnapshot(
|
||||
Id: TrackedOperationId.Parse(reader.GetString(0)),
|
||||
Kind: reader.GetString(1),
|
||||
TargetSummary: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Status: reader.GetString(3),
|
||||
RetryCount: reader.GetInt32(4),
|
||||
LastError: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
HttpStatus: reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||
CreatedAtUtc: ParseUtc(reader.GetString(7)),
|
||||
UpdatedAtUtc: ParseUtc(reader.GetString(8)),
|
||||
TerminalAtUtc: reader.IsDBNull(9) ? null : ParseUtc(reader.GetString(9)),
|
||||
SourceInstanceId: reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
SourceScript: reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||
SourceNode: reader.IsDBNull(12) ? null : reader.GetString(12));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PurgeTerminalAsync(
|
||||
DateTime olderThanUtc,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _writeGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
|
||||
|
||||
using var cmd = _writeConnection.CreateCommand();
|
||||
// Non-terminal rows (TerminalAtUtc IS NULL) are kept regardless of
|
||||
// age — the operation is still in flight.
|
||||
cmd.CommandText = """
|
||||
DELETE FROM OperationTracking
|
||||
WHERE TerminalAtUtc IS NOT NULL
|
||||
AND TerminalAtUtc < $threshold;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue(
|
||||
"$threshold",
|
||||
olderThanUtc.ToString("o", CultureInfo.InvariantCulture));
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime ParseUtc(string raw)
|
||||
{
|
||||
return DateTime.Parse(
|
||||
raw,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously disposes the tracking store and its SQLite connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SiteRuntime-024: this path does NOT bridge to async via
|
||||
/// <c>.AsTask().GetAwaiter().GetResult()</c>. Sync-over-async on a SemaphoreSlim
|
||||
/// can deadlock when invoked from a non-reentrant SyncContext (e.g. host
|
||||
/// shutdown continuations observed on the host sync context). In-flight writes
|
||||
/// at the moment of <see cref="Dispose"/> will fail their next operation
|
||||
/// against the disposed connection with <see cref="ObjectDisposedException"/> —
|
||||
/// the caller's responsibility is to ensure no concurrent operations during
|
||||
/// the synchronous dispose. Use <see cref="DisposeAsync"/> if you need to
|
||||
/// drain in-flight writes before close.
|
||||
/// </remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeState, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_writeConnection.Dispose();
|
||||
_writeGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously disposes the tracking store and its SQLite connection.
|
||||
/// Drains in-flight writes by acquiring the write gate before closing the
|
||||
/// connection, so a write currently executing a SqliteCommand completes
|
||||
/// before the connection is freed.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeState, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain any in-flight write by taking the write gate. Past this point
|
||||
// no new write can acquire the gate because _disposeState is set, so
|
||||
// the next ThrowIf check in each writer raises ObjectDisposedException.
|
||||
try
|
||||
{
|
||||
await _writeGate.WaitAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Race with another disposer that already disposed the gate — the
|
||||
// _disposeState exchange above should prevent this, but be defensive.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_writeConnection.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { _writeGate.Release(); } catch (ObjectDisposedException) { }
|
||||
_writeGate.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka" />
|
||||
<PackageReference Include="Akka.Cluster" />
|
||||
<PackageReference Include="Akka.Cluster.Tools" />
|
||||
<PackageReference Include="Akka.Streams" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests" />
|
||||
<!--
|
||||
Audit Log #23 (M4 Bundle E — Task E1): the cross-project
|
||||
DatabaseSyncEmissionEndToEndTests construct ScriptRuntimeContext.DatabaseHelper
|
||||
directly (it has an internal ctor) so the test can drive the production
|
||||
AuditingDbConnection wrapper end-to-end against a real MSSQL central
|
||||
AuditLog. Same pattern as ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.
|
||||
-->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.AuditLog.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.HealthMonitoring/ZB.MOM.WW.ScadaBridge.HealthMonitoring.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.SiteEventLogging/ZB.MOM.WW.ScadaBridge.SiteEventLogging.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.StoreAndForward/ZB.MOM.WW.ScadaBridge.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user