0ffd4bdbea
Comment/XML-doc changes only, no code logic: - strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from comments, keeping the explanatory rationale - add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/> - replace misused <inheritdoc/> with real docs; where <inheritdoc/> was combined with useful remarks, move the rationale into // comments - drop stale replicationActor <param> on DeploymentManagerActor ctor Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365). Build 0 errors / 0 warnings.
2165 lines
105 KiB
C#
2165 lines
105 KiB
C#
using Akka.Actor;
|
||
using Microsoft.CodeAnalysis.Scripting;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
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.Interfaces.Protocol;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||
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.SiteEventLogging;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
|
||
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.
|
||
/// All state mutations are serialized through the actor mailbox.
|
||
/// Multiple Script Execution Actors run concurrently; state mutations through this actor.
|
||
///
|
||
/// Creates child Script Actors and Alarm Actors on startup.
|
||
/// Tell for tag value updates, attribute notifications, stream publishing.
|
||
/// Ask for CallScript, debug snapshot.
|
||
/// 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();
|
||
|
||
/// <summary>
|
||
/// Computed-alarm canonical name → the deployed <c>ResolvedAlarm.IsMesReportable</c>
|
||
/// flag. Populated alongside <see cref="_alarmPriorities"/> when the Alarm Actor
|
||
/// children are created and read back in <see cref="BuildAlarmStatesSnapshot"/>, which
|
||
/// is the single place the flag is stamped — so a fired alarm (served from the retained
|
||
/// event) and an unfired one (served from the bare fallback projection) cannot drift.
|
||
/// Native conditions have no entry here and correctly resolve to false.
|
||
/// </summary>
|
||
private readonly Dictionary<string, bool> _alarmMesReportable = new();
|
||
private readonly Dictionary<string, IActorRef> _scriptActors = new();
|
||
private readonly Dictionary<string, IActorRef> _alarmActors = new();
|
||
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
|
||
|
||
/// <summary>
|
||
/// Per-attribute child routing (P2): monitoredAttributeName → the script/alarm actors
|
||
/// that react only to that attribute (value-change / conditional scripts;
|
||
/// HiLo/ValueMatch/RangeViolation/RateOfChange alarms). Built once in
|
||
/// <see cref="CreateChildActors"/> from each child's trigger config and consulted by
|
||
/// <see cref="PublishAndNotifyChildren"/> so an attribute change reaches only the
|
||
/// children interested in it instead of every child. The child's own trigger gate
|
||
/// remains authoritative — this routing is a fan-out optimization, not the correctness
|
||
/// check.
|
||
/// </summary>
|
||
private readonly Dictionary<string, List<IActorRef>> _childrenByMonitoredAttribute = new();
|
||
|
||
/// <summary>
|
||
/// Children that must hear EVERY attribute change (P2): Expression-triggered scripts and
|
||
/// alarms (they keep a full attribute snapshot), plus any child whose trigger config was
|
||
/// unparseable (fail-open). Complements <see cref="_childrenByMonitoredAttribute"/>.
|
||
/// </summary>
|
||
private readonly List<IActorRef> _allChangeChildren = new();
|
||
/// <summary>
|
||
/// Native alarm kind per source-binding canonical name (the same value handed
|
||
/// to each <see cref="NativeAlarmActor"/>'s <c>_nativeKind</c>). Used to stamp
|
||
/// the cosmetic kind badge on placeholder rows for quiet bindings in the
|
||
/// DebugView snapshot.
|
||
/// </summary>
|
||
private readonly Dictionary<string, AlarmKind> _nativeAlarmKinds = new();
|
||
/// <summary>
|
||
/// Latest enriched <see cref="AlarmStateChanged"/> per alarm name (computed and
|
||
/// native), so the DebugView snapshot carries the unified condition + native
|
||
/// metadata rather than a bare State/Priority projection.
|
||
/// </summary>
|
||
private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new();
|
||
private FlattenedConfiguration? _configuration;
|
||
|
||
// Resolved attributes indexed by canonical name. The TagValueUpdate
|
||
// ingest path is the highest-frequency message this actor handles, so the
|
||
// attribute lookup must be O(1) rather than a linear scan of
|
||
// _configuration.Attributes. Built once in the constructor from the
|
||
// deserialized configuration (last-wins on duplicate canonical names,
|
||
// mirroring the rest of the actor's by-name dictionaries). Each entry
|
||
// additionally carries its DataType/ElementDataType parsed ONCE at
|
||
// config-apply time so the hot
|
||
// TagValueUpdate path never re-Enum.TryParse's a string per update.
|
||
private readonly Dictionary<string, AttributeIndexEntry> _resolvedAttributeByName = new();
|
||
|
||
// Static-override write coalescing:
|
||
// mirrors NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape.
|
||
// A burst of rapid SetAttribute calls on the same or different attributes
|
||
// collapses into one batched SQLite transaction on a short single-shot
|
||
// timer instead of a write per call. Keyed by attribute name — only the
|
||
// LATEST value per attribute survives a coalescing window, which matches
|
||
// the "in-memory state is authoritative" fire-and-forget persistence model
|
||
// already documented on the single-write path this replaces.
|
||
private readonly Dictionary<string, string> _dirtyStaticOverrides = new();
|
||
private ICancelable? _staticOverrideFlushTimer;
|
||
private readonly TimeSpan _staticOverrideFlushInterval;
|
||
|
||
/// <summary>
|
||
/// The deploy path's static-override / native-alarm reset, supplied by
|
||
/// <see cref="DeploymentManagerActor.CreateInstanceActor"/> on a (re)deploy and
|
||
/// <see langword="null"/> on every other start When set,
|
||
/// <see cref="PreStart"/> starts the override load only once it has completed, so a
|
||
/// redeploy can never load the PREDECESSOR's overrides into the successor's memory.
|
||
/// </summary>
|
||
private readonly Task? _staticStateResetGate;
|
||
|
||
private Task _staticOverridePersistTail = Task.CompletedTask;
|
||
|
||
internal sealed record StaticOverridesFlushed(IActorRef Actor, Exception? Error);
|
||
|
||
/// <summary>
|
||
/// Cached string forms of <see cref="QualityCode"/> — the TagValueUpdate ingest path calls this once or twice per update, so a
|
||
/// static lookup replaces repeated <see cref="Enum.ToString()"/> reflection.
|
||
/// </summary>
|
||
private static readonly Dictionary<QualityCode, string> QualityStrings = new()
|
||
{
|
||
[QualityCode.Good] = "Good",
|
||
[QualityCode.Bad] = "Bad",
|
||
[QualityCode.Uncertain] = "Uncertain",
|
||
};
|
||
|
||
private static string QualityToString(QualityCode quality) =>
|
||
QualityStrings.TryGetValue(quality, out var s) ? s : quality.ToString();
|
||
|
||
/// <summary>
|
||
/// O(1) attribute-resolution index entry: wraps a
|
||
/// <see cref="ResolvedAttribute"/> together with its <see cref="DataType"/> /
|
||
/// element <see cref="DataType"/> / IsList classification, parsed ONCE when the entry
|
||
/// is built (constructor) instead of re-parsed via <see cref="Enum.TryParse{DataType}"/>
|
||
/// on every access.
|
||
/// </summary>
|
||
private sealed record AttributeIndexEntry(
|
||
ResolvedAttribute Attribute,
|
||
DataType DataType,
|
||
DataType? ElementDataType,
|
||
bool IsList);
|
||
|
||
// WaitForAttribute (spec §4.2): one-shot waiter registry keyed by the
|
||
// request CorrelationId. Each entry holds the watched attribute name, the
|
||
// match test (decoded target equality OR a site-local predicate), the
|
||
// original Sender to reply to, and the scheduled-timeout handle so a match
|
||
// can cancel it. Single-threaded actor access — no locking needed.
|
||
private readonly Dictionary<string, PendingWait> _attributeWaiters = new();
|
||
|
||
// WaitForAttribute: defensive per-instance cap so a script leaking waiters
|
||
// in a loop cannot grow the registry without bound. Exceeding it refuses the
|
||
// wait with an error reply rather than registering.
|
||
private const int MaxAttributeWaiters = 100;
|
||
|
||
// DCL manager actor reference for subscribing to tag values
|
||
private readonly IActorRef? _dclManager;
|
||
|
||
/// <summary>
|
||
/// Flow Debugger trace arm (design 2026-09-05 §5.3): attribute → (writer's ExecutionId,
|
||
/// expiry) recorded when a TRACED script write targets a data-sourced attribute. The next
|
||
/// DCL value change for that attribute is published stamped with the writer's id and
|
||
/// <c>Trace</c>, and the arm is consumed. Bounded by the attribute count (one entry per
|
||
/// attribute, newest wins); no sweep — an unconsumed arm is overwritten or expires in place.
|
||
/// Armed at ISSUE, disarmed on a failed/timed-out write (arch-review F6, <see cref="DisarmTrace"/>);
|
||
/// the first subsequent device update consumes it, with explicitly inferred linkage.
|
||
/// </summary>
|
||
private readonly Dictionary<string, (Guid SourceExecutionId, DateTimeOffset ExpiresAt)> _traceArms = new();
|
||
// 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();
|
||
|
||
// Tag-subscription retry state (S4/UA6 + Lever 1 2026-08-28): tag paths grouped by
|
||
// connection name (built once in SubscribeToDcl), the in-flight correlation-id →
|
||
// connection map, and per-connection retry state. Arming the watchdog on *send*
|
||
// closes both the failed-response and the lost-response gaps with one mechanism;
|
||
// it is cancelled when a Success reply arrives. Lever 1 adds the in-flight guard
|
||
// (a tick while a young request is outstanding re-arms instead of re-sending) and
|
||
// exponential backoff (see NextTagSubscribeInterval) so a slow gateway is never
|
||
// handed a fresh N-tag subscribe on top of the one it is still processing.
|
||
// SubscribeToDcl has a single caller today (PreStart); the prune at its top is defensive, so a
|
||
// future second call site rebuilds the retry state and both tag maps together instead of stranding them.
|
||
private readonly Dictionary<string, List<string>> _tagsByConnection = new();
|
||
private readonly Dictionary<string, string> _pendingTagSubscribes = new();
|
||
private readonly Dictionary<string, TagSubscribeState> _tagSubscribeStates = new();
|
||
|
||
/// <summary>Per-connection tag-subscribe retry bookkeeping (Lever 1).</summary>
|
||
private sealed class TagSubscribeState
|
||
{
|
||
/// <summary>Correlation id of the outstanding request, null when none is in flight.</summary>
|
||
public string? InFlightCorrelationId;
|
||
/// <summary>When the outstanding request was sent (monotonic, for the ceiling check).</summary>
|
||
public long InFlightSentTicks;
|
||
/// <summary>Interval the NEXT retry will use; starts at the floor, grows on failure.</summary>
|
||
public TimeSpan Interval;
|
||
/// <summary>The armed watchdog for this connection.</summary>
|
||
public ICancelable? Timer;
|
||
}
|
||
|
||
/// <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>
|
||
/// <param name="staticOverrideFlushInterval">Coalescing window for buffered static-override writes.</param>
|
||
/// <param name="staticStateResetGate">
|
||
/// Optional (re)deploy static-override / native-alarm reset to order the PreStart override
|
||
/// load behind — see <see cref="_staticStateResetGate"/>.
|
||
/// </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,
|
||
TimeSpan? staticOverrideFlushInterval = null,
|
||
Task? staticStateResetGate = null)
|
||
{
|
||
_instanceUniqueName = instanceUniqueName;
|
||
_storage = storage;
|
||
_compilationService = compilationService;
|
||
_sharedScriptLibrary = sharedScriptLibrary;
|
||
_streamManager = streamManager;
|
||
_options = options;
|
||
_logger = logger;
|
||
_dclManager = dclManager;
|
||
_healthCollector = healthCollector;
|
||
_serviceProvider = serviceProvider;
|
||
// Defaults to 100 ms, mirroring NativeAlarmActor's persistFlushInterval (P4).
|
||
_staticOverrideFlushInterval = staticOverrideFlushInterval ?? TimeSpan.FromMilliseconds(100);
|
||
_staticStateResetGate = staticStateResetGate;
|
||
|
||
// 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)
|
||
{
|
||
// Parse DataType/ElementDataType ONCE here (config-apply time) and index
|
||
// the wrapping entry for O(1) lookup on the hot TagValueUpdate ingest path
|
||
// (last-wins on duplicate names)
|
||
var dataType = ParseDataType(attr.DataType);
|
||
var elementDataType = ParseElementDataType(attr.ElementDataType);
|
||
var isList = dataType == DataType.List;
|
||
_resolvedAttributeByName[attr.CanonicalName] =
|
||
new AttributeIndexEntry(attr, dataType, elementDataType, isList);
|
||
|
||
// A STATIC List attribute's default is the canonical JSON
|
||
// array string. Decode it to a typed List<T> for in-memory reads
|
||
// so scripts see a real collection. Scalars store their raw
|
||
// string unchanged. A malformed List default decodes to null and
|
||
// is marked Bad quality rather than crashing the actor.
|
||
if (isList)
|
||
{
|
||
var decoded = DecodeAttributeValue(dataType, elementDataType, attr.Value, attr.CanonicalName);
|
||
_attributes[attr.CanonicalName] = decoded;
|
||
_attributeQualities[attr.CanonicalName] =
|
||
decoded is null && !string.IsNullOrEmpty(attr.Value) ? "Bad"
|
||
: string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
|
||
}
|
||
else
|
||
{
|
||
_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);
|
||
|
||
// 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.)
|
||
|
||
// Handle script call requests — route to appropriate Script Actor (Ask pattern)
|
||
Receive<ScriptCallRequest>(HandleScriptCallRequest);
|
||
|
||
// Handle attribute value changes from DCL (Tell pattern)
|
||
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
|
||
|
||
// WaitForAttribute (spec §4.2): event-driven "wait for value" waiter
|
||
// registration + its scheduled-timeout self-message. Both flow only
|
||
// site-locally (the predicate variant carries a non-serializable delegate).
|
||
Receive<WaitForAttributeRequest>(HandleWaitForAttribute);
|
||
Receive<WaitForAttributeTimeout>(HandleWaitForAttributeTimeout);
|
||
|
||
// Batch write + (event-driven) wait: resolves a set of data-sourced
|
||
// attributes to one DCL connection and forwards a single WriteTagBatchRequest.
|
||
// Backs the script-facing Attributes.WriteBatchAndWaitAsync helper; the wait
|
||
// half is the WaitForAttribute waiter above.
|
||
Receive<WriteAttributeBatchRequest>(HandleWriteAttributeBatch);
|
||
|
||
// Handle tag value updates from DCL — convert to AttributeValueChanged
|
||
Receive<TagValueUpdate>(HandleTagValueUpdate);
|
||
Receive<SubscribeTagsResponse>(HandleSubscribeTagsResponse); // S4/UA6: real retry-aware handler
|
||
Receive<RetryTagSubscribe>(msg => SendTagSubscribe(msg.ConnectionName));
|
||
|
||
// Arch-review F6: a traced write the DCL rejected (or that timed out) drops its own arm,
|
||
// so a later update is not associated with that failed write.
|
||
Receive<DisarmTrace>(msg =>
|
||
{
|
||
if (_traceArms.TryGetValue(msg.AttributeName, out var arm)
|
||
&& arm.SourceExecutionId == msg.SourceExecutionId)
|
||
{
|
||
_traceArms.Remove(msg.AttributeName);
|
||
}
|
||
});
|
||
Receive<ConnectionQualityChanged>(HandleConnectionQualityChanged);
|
||
|
||
// Handle alarm state changes from Alarm Actors (Tell pattern)
|
||
Receive<AlarmStateChanged>(HandleAlarmStateChanged);
|
||
|
||
// A NativeAlarmActor tells us when one of its native
|
||
// conditions has left the mirror for good (snapshot-swap removal, retention
|
||
// drop, or cap eviction) so we can evict the stale _latestAlarmEvents key
|
||
// and not leak per-instance memory / bloat DebugView snapshots.
|
||
Receive<NativeAlarmDropped>(HandleNativeAlarmDropped);
|
||
|
||
// Debug view subscribe/unsubscribe (Ask pattern for snapshot)
|
||
Receive<SubscribeDebugViewRequest>(HandleSubscribeDebugView);
|
||
Receive<UnsubscribeDebugViewRequest>(HandleUnsubscribeDebugView);
|
||
|
||
// Debug snapshot (one-shot, no subscription)
|
||
Receive<DebugSnapshotRequest>(HandleDebugSnapshot);
|
||
|
||
// Script-facing alarm snapshot backing Alarms.CurrentAsync()
|
||
Receive<GetAlarmSnapshotRequest>(HandleGetAlarmSnapshot);
|
||
|
||
// Handle internal messages
|
||
Receive<LoadOverridesResult>(HandleOverridesLoaded);
|
||
|
||
// Coalesced flush of buffered static-override writes (P4 pattern, see
|
||
// NativeAlarmActor.FlushDirtyUpserts).
|
||
Receive<FlushStaticOverrides>(_ => FlushDirtyStaticOverrides());
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void PreStart()
|
||
{
|
||
base.PreStart();
|
||
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
|
||
|
||
// Operational `instance_lifecycle` event — instance started.
|
||
// An instance starts on deploy, on enable (DeploymentManager re-creates
|
||
// the actor), and on failover/restart; this single point covers them all.
|
||
LogLifecycleEvent($"Instance {_instanceUniqueName} started");
|
||
|
||
// Asynchronously load static overrides from SQLite and pipe to self. On a (re)deploy
|
||
// the read is chained behind the reset the Deployment Manager dispatched before this
|
||
// actor existed, so it can only ever observe the CLEARED table. A faulted reset does not block the load — the deploy itself fails
|
||
// on that fault through the persistence join, which stops this actor anyway.
|
||
var self = Self;
|
||
var instanceName = _instanceUniqueName;
|
||
var storage = _storage;
|
||
var load = _staticStateResetGate == null
|
||
? storage.GetStaticOverridesAsync(instanceName)
|
||
: _staticStateResetGate
|
||
.ContinueWith(_ => storage.GetStaticOverridesAsync(instanceName))
|
||
.Unwrap();
|
||
load.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();
|
||
|
||
// Init succeeded — tell the Deployment Manager so it can confirm the
|
||
// deployment (S6). If any step above throws, PreStart aborts before this
|
||
// line and the actor is stopped with ActorInitializationException instead,
|
||
// so the DM never sees a readiness signal for an instance that failed to init.
|
||
Context.Parent.Tell(new InstanceActorInitialized(_instanceUniqueName));
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
protected override void PostStop()
|
||
{
|
||
foreach (var connectionName in _tagsByConnection.Keys)
|
||
_dclManager?.Tell(new UnsubscribeTagsRequest(
|
||
Guid.NewGuid().ToString("N"), _instanceUniqueName, connectionName, DateTimeOffset.UtcNow), Self);
|
||
|
||
// Cancel any armed tag-subscribe watchdogs so they cannot fire after stop.
|
||
foreach (var state in _tagSubscribeStates.Values)
|
||
state.Timer?.Cancel();
|
||
_tagSubscribeStates.Clear();
|
||
|
||
// SQLite's async APIs may block synchronously. Persist on the ordered
|
||
// thread-pool chain and acknowledge completion separately from Terminated.
|
||
_staticOverrideFlushTimer?.Cancel();
|
||
var stoppedActor = Self;
|
||
FlushDirtyStaticOverrides(final: true).PipeTo(Context.Parent, Self,
|
||
success: () => new StaticOverridesFlushed(stoppedActor, null),
|
||
failure: error => new StaticOverridesFlushed(stoppedActor, error));
|
||
|
||
// Operational `instance_lifecycle` event — instance stopped. An
|
||
// instance stops on disable, delete, redeployment, and graceful shutdown;
|
||
// this single point covers them all.
|
||
LogLifecycleEvent($"Instance {_instanceUniqueName} stopped");
|
||
base.PostStop();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fire-and-forget an <c>instance_lifecycle</c> operational event to the
|
||
/// optional <see cref="ISiteEventLogger"/>. Resolved optionally and never
|
||
/// awaited so a logging failure cannot affect the instance lifecycle
|
||
/// (matching the established ScriptActor / script-run pattern).
|
||
/// </summary>
|
||
private void LogLifecycleEvent(string message)
|
||
{
|
||
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
|
||
"instance_lifecycle", "Info", _instanceUniqueName,
|
||
$"InstanceActor:{_instanceUniqueName}", message);
|
||
}
|
||
|
||
/// <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).
|
||
/// 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)
|
||
{
|
||
// O(1) lookup via the existing hot-path index instead of a linear scan over
|
||
// _configuration.Attributes.
|
||
_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved);
|
||
|
||
// 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.Attribute.DataSourceReference)
|
||
&& !string.IsNullOrEmpty(resolved.Attribute.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)
|
||
{
|
||
// command.Value is the canonical form — a plain string for scalars,
|
||
// a JSON array string for List attributes. For a List attribute we store
|
||
// the DECODED typed list in memory (so scripts read a real collection) but
|
||
// persist + publish the canonical JSON string UNCHANGED below. Scalars
|
||
// store the string verbatim. (HandleSetStaticAttribute already rejected
|
||
// unknown attributes, so resolved is non-null here, but guard defensively.)
|
||
if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved)
|
||
&& resolved.IsList)
|
||
{
|
||
// The script path pre-encodes valid canonical JSON via ScopeAccessors,
|
||
// but the Inbound API / direct-command path can submit an arbitrary
|
||
// command.Value. A non-empty value that fails to decode (malformed JSON,
|
||
// bad element, missing element type) is poison: storing it would null the
|
||
// in-memory value yet publish "Good" quality and durably persist the bad
|
||
// JSON (which then loads as Bad next restart). Reject such writes outright.
|
||
// Note: DecodeAttributeValue returns null for BOTH a null/empty input
|
||
// (valid — clearing) AND a malformed non-empty input (invalid). Only the
|
||
// latter is rejected, hence the explicit IsNullOrWhiteSpace guard. An empty
|
||
// list "[]" decodes to a non-null empty List, so it passes through.
|
||
var decoded = DecodeAttributeValue(
|
||
resolved.DataType, resolved.ElementDataType, command.Value, command.AttributeName);
|
||
if (!string.IsNullOrWhiteSpace(command.Value) && decoded == null)
|
||
{
|
||
_logger.LogWarning(
|
||
"SetAttribute rejected — value for List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
|
||
command.AttributeName, _instanceUniqueName);
|
||
Sender.Tell(new SetStaticAttributeResponse(
|
||
command.CorrelationId,
|
||
_instanceUniqueName,
|
||
command.AttributeName,
|
||
false,
|
||
$"Invalid list value for attribute '{command.AttributeName}'",
|
||
DateTimeOffset.UtcNow));
|
||
return;
|
||
}
|
||
|
||
_attributes[command.AttributeName] = decoded;
|
||
}
|
||
else
|
||
{
|
||
_attributes[command.AttributeName] = command.Value;
|
||
}
|
||
|
||
// Publish attribute change to stream and notify children
|
||
var changed = new AttributeValueChanged(
|
||
_instanceUniqueName,
|
||
command.AttributeName,
|
||
command.AttributeName,
|
||
command.Value,
|
||
"Good",
|
||
DateTimeOffset.UtcNow,
|
||
// (ParentExecutionId tag-cascade): carry the writing
|
||
// execution's id onto the change so a child Alarm Actor this write
|
||
// trips can chain its on-trigger script run under the writer. Null for
|
||
// writes with no audited origin (central Test Run, deployment tooling).
|
||
SourceExecutionId: command.SourceExecutionId,
|
||
// Flow Debugger (design §5.2): a traced write's change carries the flag so the
|
||
// value-change run it spawns is traced and parented to the writer.
|
||
Trace: command.Trace);
|
||
|
||
PublishAndNotifyChildren(changed);
|
||
|
||
// Coalesce the persist: record the latest value and arm the short batched-flush
|
||
// timer (P4 pattern) instead of firing a SQLite write per call — the actor's
|
||
// in-memory state (already updated above) remains the source of truth regardless
|
||
// of when the buffered write lands.
|
||
MarkDirtyStaticOverride(command.AttributeName, command.Value);
|
||
|
||
Sender.Tell(new SetStaticAttributeResponse(
|
||
command.CorrelationId, _instanceUniqueName, command.AttributeName,
|
||
true, null, DateTimeOffset.UtcNow));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Records the latest value per attribute name and arms the coalesced flush timer
|
||
/// — mirrors NativeAlarmActor's
|
||
/// MarkDirtyUpsert/FlushDirtyUpserts (P4) shape. A burst of rapid static writes
|
||
/// collapses into one batched SQLite transaction instead of a write per call.
|
||
/// </summary>
|
||
private void MarkDirtyStaticOverride(string attributeName, string value)
|
||
{
|
||
_dirtyStaticOverrides[attributeName] = value;
|
||
if (_staticOverrideFlushTimer == null)
|
||
{
|
||
_staticOverrideFlushTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||
_staticOverrideFlushInterval, Self, FlushStaticOverrides.Instance, Self);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Persists all coalesced static-override writes in one batched transaction (P4),
|
||
/// then disarms the flush timer. Fire-and-forget with OnlyOnFaulted logging — a
|
||
/// failed write never blocks the actor; the in-memory attribute value (already
|
||
/// applied) remains authoritative.
|
||
/// </summary>
|
||
/// <returns>
|
||
/// The ordered persist task, acknowledged separately from termination so a redeploy
|
||
/// can wait for the final commit. Every other caller ignores it, keeping
|
||
/// the fire-and-forget semantics unchanged. Completed when there was nothing dirty.
|
||
/// </returns>
|
||
private Task FlushDirtyStaticOverrides(bool final = false)
|
||
{
|
||
_staticOverrideFlushTimer = null;
|
||
if (_dirtyStaticOverrides.Count == 0)
|
||
return _staticOverridePersistTail;
|
||
if (!final && !_staticOverridePersistTail.IsCompleted)
|
||
{
|
||
// Keep coalescing while SQLite is busy instead of accumulating an
|
||
// unbounded task/batch queue behind a stalled transaction.
|
||
_staticOverrideFlushTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||
_staticOverrideFlushInterval, Self, FlushStaticOverrides.Instance, Self);
|
||
return _staticOverridePersistTail;
|
||
}
|
||
|
||
var batch = _dirtyStaticOverrides
|
||
.Select(kv => (AttributeName: kv.Key, Value: kv.Value))
|
||
.ToList();
|
||
_dirtyStaticOverrides.Clear();
|
||
|
||
var instanceName = _instanceUniqueName;
|
||
var logger = _logger;
|
||
var previous = _staticOverridePersistTail;
|
||
var persist = Task.Run(async () =>
|
||
{
|
||
try { await previous.ConfigureAwait(false); }
|
||
catch { /* The prior batch was logged; a transient failure must not poison future writes. */ }
|
||
await _storage.SetStaticOverridesAsync(instanceName, batch).ConfigureAwait(false);
|
||
});
|
||
_staticOverridePersistTail = persist;
|
||
persist.ContinueWith(t => logger.LogWarning(
|
||
t.Exception?.GetBaseException(),
|
||
"Failed to persist {Count} static overrides for {Instance}; in-memory state is authoritative",
|
||
batch.Count, instanceName),
|
||
TaskContinuationOptions.OnlyOnFaulted);
|
||
return persist;
|
||
}
|
||
|
||
/// <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, AttributeIndexEntry 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;
|
||
}
|
||
|
||
// MV: for a data-sourced List attribute the incoming command.Value is
|
||
// the canonical JSON array string (ScopeAccessors encodes the script's
|
||
// List<T> for transport/storage). Writing that string straight to the DCL
|
||
// would push a String scalar to an array node. Decode it back to a typed
|
||
// List<T> so the DCL/Variant write produces a real array. A non-empty value
|
||
// that fails to decode (malformed JSON / bad element) is poison — reject the
|
||
// write rather than forward garbage to the device (mirrors the static-path
|
||
// rejection in HandleSetStaticAttributeCore). Scalars are unchanged.
|
||
object? writeValue = command.Value;
|
||
if (resolved.IsList && !string.IsNullOrWhiteSpace(command.Value))
|
||
{
|
||
var decoded = DecodeAttributeValue(
|
||
resolved.DataType, resolved.ElementDataType, command.Value, attributeName);
|
||
if (decoded == null)
|
||
{
|
||
_logger.LogWarning(
|
||
"SetAttribute rejected — value for data-sourced List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
|
||
attributeName, instanceName);
|
||
caller.Tell(new SetStaticAttributeResponse(
|
||
correlationId, instanceName, attributeName, false,
|
||
$"Invalid list value for attribute '{attributeName}'", DateTimeOffset.UtcNow));
|
||
return;
|
||
}
|
||
|
||
writeValue = decoded;
|
||
}
|
||
|
||
var writeRequest = new WriteTagRequest(
|
||
correlationId,
|
||
resolved.Attribute.BoundDataConnectionName!,
|
||
resolved.Attribute.DataSourceReference!,
|
||
writeValue,
|
||
DateTimeOffset.UtcNow);
|
||
|
||
// Flow Debugger trace arm (design §5.3): only a TRACED write with a known writer arms;
|
||
// the next device update is marked as inferred in HandleTagValueUpdate. Untraced writes
|
||
// never touch the map, so production behaviour is unchanged. The arm is taken at ISSUE
|
||
// and dropped again on a failed/timed-out write (arch-review F6, via the DisarmTrace
|
||
// self-message below); the first subsequent device update consumes it, with explicitly inferred linkage.
|
||
Guid? armed = null;
|
||
if (command.Trace && command.SourceExecutionId is { } traceSource)
|
||
{
|
||
_traceArms[attributeName] = (traceSource, DateTimeOffset.UtcNow.AddSeconds(_options.TraceArmWindowSeconds));
|
||
armed = traceSource;
|
||
}
|
||
|
||
// Captured for the continuation: it runs OFF the actor's dispatcher, so it must never
|
||
// touch _traceArms directly — it Tells self instead.
|
||
var self = Self;
|
||
|
||
// 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 =>
|
||
{
|
||
var succeeded = t.IsCompletedSuccessfully && t.Result.Success;
|
||
if (!succeeded && armed is { } armedSource)
|
||
self.Tell(new DisarmTrace(attributeName, armedSource));
|
||
|
||
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>
|
||
/// Batch write: resolves a SET of data-sourced attributes (and an optional trigger
|
||
/// attribute) to their device bindings, enforces a single data connection across the
|
||
/// whole batch, decodes List values (same rule as <see cref="HandleSetDataAttribute"/>),
|
||
/// and forwards ONE <see cref="WriteTagBatchRequest"/> to the DCL — replacing N
|
||
/// sequential per-attribute writes with a single gateway round-trip. The write outcome
|
||
/// is returned synchronously to the caller; the EVENT-DRIVEN wait for the response
|
||
/// attribute is performed separately by the caller via the existing WaitForAttribute
|
||
/// waiter (this handler does not wait). Resolution mirrors the data-sourced
|
||
/// SetAttribute path: an attribute is data-sourced only when it resolves AND has both a
|
||
/// <see cref="ResolvedAttribute.DataSourceReference"/> and a
|
||
/// <see cref="ResolvedAttribute.BoundDataConnectionName"/>.
|
||
/// </summary>
|
||
private void HandleWriteAttributeBatch(WriteAttributeBatchRequest request)
|
||
{
|
||
var caller = Sender;
|
||
var cid = request.CorrelationId;
|
||
|
||
if (_dclManager == null)
|
||
{
|
||
caller.Tell(new WriteAttributeBatchResponse(
|
||
cid, false, "Data Connection Layer not available for write."));
|
||
return;
|
||
}
|
||
|
||
var values = new Dictionary<string, object?>();
|
||
string? connName = null;
|
||
|
||
foreach (var kv in request.AttributeEncodedValues)
|
||
{
|
||
if (!_resolvedAttributeByName.TryGetValue(kv.Key, out var resolved)
|
||
|| string.IsNullOrEmpty(resolved.Attribute.DataSourceReference)
|
||
|| string.IsNullOrEmpty(resolved.Attribute.BoundDataConnectionName))
|
||
{
|
||
caller.Tell(new WriteAttributeBatchResponse(
|
||
cid, false, $"Attribute '{kv.Key}' is not a data-sourced attribute."));
|
||
return;
|
||
}
|
||
|
||
if (connName == null)
|
||
connName = resolved.Attribute.BoundDataConnectionName;
|
||
else if (!string.Equals(connName, resolved.Attribute.BoundDataConnectionName, StringComparison.Ordinal))
|
||
{
|
||
caller.Tell(new WriteAttributeBatchResponse(
|
||
cid, false, "Batch write spans multiple data connections; not supported."));
|
||
return;
|
||
}
|
||
|
||
object? wv = kv.Value;
|
||
// MV: a data-sourced List attribute's encoded value is the canonical JSON
|
||
// array string — decode it to a typed List<T> so the DCL/Variant write
|
||
// produces a real array (same poison-rejection rule as HandleSetDataAttribute).
|
||
if (resolved.IsList && !string.IsNullOrWhiteSpace(kv.Value))
|
||
{
|
||
var decoded = DecodeAttributeValue(
|
||
resolved.DataType, resolved.ElementDataType, kv.Value, kv.Key);
|
||
if (decoded == null)
|
||
{
|
||
caller.Tell(new WriteAttributeBatchResponse(
|
||
cid, false, $"Invalid list value for attribute '{kv.Key}'"));
|
||
return;
|
||
}
|
||
wv = decoded;
|
||
}
|
||
|
||
values[resolved.Attribute.DataSourceReference!] = wv;
|
||
}
|
||
|
||
string? triggerPath = null;
|
||
object? triggerVal = null;
|
||
if (!string.IsNullOrEmpty(request.TriggerAttribute))
|
||
{
|
||
if (!_resolvedAttributeByName.TryGetValue(request.TriggerAttribute, out var tr)
|
||
|| string.IsNullOrEmpty(tr.Attribute.DataSourceReference)
|
||
|| string.IsNullOrEmpty(tr.Attribute.BoundDataConnectionName))
|
||
{
|
||
caller.Tell(new WriteAttributeBatchResponse(
|
||
cid, false, $"Trigger attribute '{request.TriggerAttribute}' is not data-sourced."));
|
||
return;
|
||
}
|
||
|
||
if (connName != null && !string.Equals(connName, tr.Attribute.BoundDataConnectionName, StringComparison.Ordinal))
|
||
{
|
||
caller.Tell(new WriteAttributeBatchResponse(
|
||
cid, false, "Trigger attribute is on a different data connection."));
|
||
return;
|
||
}
|
||
|
||
connName ??= tr.Attribute.BoundDataConnectionName;
|
||
triggerPath = tr.Attribute.DataSourceReference;
|
||
triggerVal = request.TriggerEncodedValue;
|
||
}
|
||
|
||
if (connName == null)
|
||
{
|
||
caller.Tell(new WriteAttributeBatchResponse(cid, false, "No attributes to write."));
|
||
return;
|
||
}
|
||
|
||
var dclReq = new WriteTagBatchRequest(
|
||
cid, connName!, values, triggerPath, triggerVal, DateTimeOffset.UtcNow);
|
||
|
||
// Ask the DCL and pipe the batch outcome back to the original caller. The DCL
|
||
// bounds its own write with WriteTimeout; this Ask is bounded a bit longer so a
|
||
// device timeout is surfaced as a DCL failure rather than an Ask timeout.
|
||
_dclManager.Ask<WriteTagBatchResponse>(dclReq, TimeSpan.FromSeconds(30))
|
||
.ContinueWith(t => t.IsCompletedSuccessfully
|
||
? new WriteAttributeBatchResponse(cid, t.Result.Success, t.Result.ErrorMessage)
|
||
: new WriteAttributeBatchResponse(
|
||
cid, false, t.Exception?.GetBaseException().Message ?? "DCL batch write timed out"))
|
||
.PipeTo(caller);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Routes script call requests to the appropriate Script Actor.
|
||
/// Uses Ask pattern.
|
||
/// </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 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>
|
||
/// 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)
|
||
{
|
||
// State mutation serialized through this actor
|
||
_attributes[changed.AttributeName] = changed.Value;
|
||
_attributeQualities[changed.AttributeName] = changed.Quality;
|
||
_attributeTimestamps[changed.AttributeName] = changed.Timestamp;
|
||
|
||
PublishAndNotifyChildren(changed);
|
||
}
|
||
|
||
/// <summary>
|
||
/// WaitForAttribute (spec §4.2): registers a one-shot event-driven waiter for
|
||
/// an attribute to reach a value (encoded-equality), satisfy a site-local
|
||
/// predicate, or change at all. The current-value fast-path and the
|
||
/// change-handling in <see cref="HandleAttributeValueChanged"/> both run on
|
||
/// this single-threaded actor, so a value that flips between "read current"
|
||
/// and "register" cannot be missed (spec §5).
|
||
/// </summary>
|
||
private void HandleWaitForAttribute(WaitForAttributeRequest req)
|
||
{
|
||
// Capture the sender immediately — Sender is invalid once we schedule /
|
||
// return and a later message arrives.
|
||
var replyer = Sender;
|
||
|
||
// Build the match test: explicit predicate wins; else null encoded target
|
||
// means "any change"; else compare the codec-encoded current value to the
|
||
// encoded target (avoids needing the attribute's DataType to decode).
|
||
Func<object?, bool> test;
|
||
if (req.Predicate is not null)
|
||
{
|
||
test = req.Predicate;
|
||
}
|
||
else if (req.TargetValueEncoded is null)
|
||
{
|
||
test = _ => true;
|
||
}
|
||
else
|
||
{
|
||
var target = req.TargetValueEncoded;
|
||
test = v => string.Equals(
|
||
AttributeValueCodec.Encode(v), target, StringComparison.Ordinal);
|
||
}
|
||
|
||
// Fast path: the current value already satisfies the test → reply now.
|
||
// A script-supplied predicate (or the codec-equality lambda) runs on the
|
||
// actor thread; guard it so a throwing predicate cannot crash the actor or
|
||
// leak a never-resolved waiter. On throw: reply non-matched + ErrorMessage
|
||
// and return WITHOUT registering (no timeout scheduled).
|
||
if (_attributes.TryGetValue(req.AttributeName, out var current))
|
||
{
|
||
// Effective quality used for BOTH the §4.2 quality gate and the match
|
||
// reply — the same `?? "Good"` default the reply has always used.
|
||
_attributeQualities.TryGetValue(req.AttributeName, out var fastQuality);
|
||
var effectiveQuality = fastQuality ?? "Good";
|
||
|
||
bool fastMatch;
|
||
try
|
||
{
|
||
// §4.2 quality gate ANDed with the value test, both INSIDE the guard:
|
||
// in quality-gated mode a value already at target but at Bad/Uncertain
|
||
// quality is NOT a fast match — it falls through to register + schedule
|
||
// the timeout like any other pending waiter (do NOT fast-reply matched).
|
||
fastMatch =
|
||
(!req.RequireGoodQuality
|
||
|| string.Equals(effectiveQuality, "Good", StringComparison.Ordinal))
|
||
&& test(current);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex,
|
||
"WaitForAttribute predicate threw on the fast-path for {Instance}.{Attribute}; refusing the wait",
|
||
_instanceUniqueName, req.AttributeName);
|
||
replyer.Tell(new WaitForAttributeResponse(
|
||
req.CorrelationId, Matched: false, null, null, TimedOut: false,
|
||
ErrorMessage: "Wait predicate threw: " + ex.Message));
|
||
return;
|
||
}
|
||
|
||
if (fastMatch)
|
||
{
|
||
replyer.Tell(new WaitForAttributeResponse(
|
||
req.CorrelationId, Matched: true, current, effectiveQuality, TimedOut: false));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Defensive cap: refuse rather than register if the instance already has
|
||
// too many concurrent waiters (guards against a script leaking waiters).
|
||
if (_attributeWaiters.Count >= MaxAttributeWaiters)
|
||
{
|
||
replyer.Tell(new WaitForAttributeResponse(
|
||
req.CorrelationId, Matched: false, null, null, TimedOut: false,
|
||
ErrorMessage: "Too many concurrent attribute waiters on this instance"));
|
||
return;
|
||
}
|
||
|
||
// Register and schedule the self-evicting timeout (NativeAlarmActor idiom).
|
||
var handle = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||
req.Timeout, Self, new WaitForAttributeTimeout(req.CorrelationId), Self);
|
||
|
||
_attributeWaiters[req.CorrelationId] =
|
||
new PendingWait(req.AttributeName, test, replyer, handle, req.RequireGoodQuality);
|
||
}
|
||
|
||
/// <summary>
|
||
/// WaitForAttribute (spec §4.2): the scheduled timeout fired for a waiter that
|
||
/// never matched. If still registered (a match would have removed + canceled
|
||
/// it), reply TimedOut and evict it.
|
||
/// </summary>
|
||
private void HandleWaitForAttributeTimeout(WaitForAttributeTimeout msg)
|
||
{
|
||
if (_attributeWaiters.Remove(msg.CorrelationId, out var pending))
|
||
{
|
||
pending.Replyer.Tell(new WaitForAttributeResponse(
|
||
msg.CorrelationId, Matched: false, null, null, TimedOut: true));
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
|
||
// One tag path may back several attributes — update every one of them.
|
||
// Each attribute is coerced according to its own declared data type, so
|
||
// we resolve and convert per attribute rather than once for the tag.
|
||
foreach (var attrName in attrNames)
|
||
{
|
||
// O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
|
||
_resolvedAttributeByName.TryGetValue(attrName, out var resolved);
|
||
|
||
// A device subscription has no write-request identity. Associate only the first
|
||
// update inside the arm window, explicitly as inferred: a periodic publish may
|
||
// win this race, and equality cannot establish causality (devices can coerce values).
|
||
Guid? traceSource = null;
|
||
var traced = false;
|
||
if (_traceArms.Remove(attrName, out var arm) && arm.ExpiresAt > DateTimeOffset.UtcNow)
|
||
{
|
||
traceSource = arm.SourceExecutionId;
|
||
traced = true;
|
||
}
|
||
|
||
// A List-typed attribute coerces the incoming OPC UA array
|
||
// (a CLR array/IEnumerable from the SDK) into a typed List<T>. On an
|
||
// element-type mismatch we set the attribute's quality to Bad, log a
|
||
// warning, and skip storing a value rather than crashing the actor.
|
||
if (resolved != null && resolved.IsList)
|
||
{
|
||
if (TryCoerceListValue(resolved.ElementDataType, update.Value, out var typedList))
|
||
{
|
||
HandleAttributeValueChanged(new AttributeValueChanged(
|
||
_instanceUniqueName, update.TagPath, attrName,
|
||
typedList, QualityToString(update.Quality), update.Timestamp,
|
||
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced));
|
||
}
|
||
else
|
||
{
|
||
_logger.LogWarning(
|
||
"List attribute {Instance}.{Attribute} received a value that could not be coerced to List<{Element}>; marking quality Bad",
|
||
_instanceUniqueName, attrName, resolved.Attribute.ElementDataType);
|
||
_attributeQualities[attrName] = "Bad";
|
||
_attributeTimestamps[attrName] = update.Timestamp;
|
||
var currentValue = _attributes.GetValueOrDefault(attrName);
|
||
// WaitForAttribute (spec §4.2): quality-only republish — the
|
||
// stored value is UNCHANGED (we publish the OLD currentValue, only
|
||
// the quality flips to Bad). Do NOT evaluate waiters, or an
|
||
// "any-change" / unchanged-value-equality waiter would fire on a
|
||
// non-change.
|
||
PublishAndNotifyChildren(new AttributeValueChanged(
|
||
_instanceUniqueName, update.TagPath, attrName,
|
||
currentValue, "Bad", update.Timestamp,
|
||
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced), evaluateWaiters: false);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Scalars and non-List attributes keep the historical behaviour:
|
||
// array values are normalized to JSON strings so they survive Akka
|
||
// serialization; scalars pass through unchanged.
|
||
var value = update.Value is Array
|
||
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
|
||
: update.Value;
|
||
|
||
HandleAttributeValueChanged(new AttributeValueChanged(
|
||
_instanceUniqueName, update.TagPath, attrName,
|
||
value, QualityToString(update.Quality), update.Timestamp,
|
||
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced));
|
||
}
|
||
}
|
||
|
||
/// <summary>Parses a <see cref="ResolvedAttribute.DataType"/> string, defaulting to
|
||
/// <see cref="DataType.String"/> on an unrecognized value (never throws).</summary>
|
||
private static DataType ParseDataType(string dataType) =>
|
||
Enum.TryParse<DataType>(dataType, ignoreCase: true, out var dt) ? dt : DataType.String;
|
||
|
||
/// <summary>Parses a <see cref="ResolvedAttribute.ElementDataType"/> string, returning
|
||
/// <see langword="null"/> when absent or unrecognized (never throws).</summary>
|
||
private static DataType? ParseElementDataType(string? elementDataType) =>
|
||
string.IsNullOrEmpty(elementDataType)
|
||
? null
|
||
: (Enum.TryParse<DataType>(elementDataType, ignoreCase: true, out var et) ? et : (DataType?)null);
|
||
|
||
/// <summary>
|
||
/// Decodes a STATIC (authored / overridden) attribute's canonical value
|
||
/// for in-memory storage. List attributes carry a canonical JSON array string
|
||
/// (config default or persisted override) which is decoded via
|
||
/// <see cref="AttributeValueCodec.Decode"/> into a typed <c>List<T></c>
|
||
/// so scripts read a real collection; scalars pass through unchanged. This is
|
||
/// the authored counterpart to <see cref="TryCoerceListValue"/> (which
|
||
/// coerces live OPC UA CLR arrays). An undecodable List value (malformed JSON,
|
||
/// bad element, missing element type) degrades to <see langword="null"/> + a
|
||
/// warning — the caller marks the attribute Bad quality. NEVER throws into the
|
||
/// actor.
|
||
///
|
||
/// Takes the already-parsed <paramref name="dataType"/>/<paramref name="elementType"/>
|
||
/// rather than re-parsing from the attribute's
|
||
/// raw DataType strings — every call site now resolves these once via
|
||
/// <see cref="AttributeIndexEntry"/> at config-apply time.
|
||
/// </summary>
|
||
private object? DecodeAttributeValue(
|
||
DataType dataType, DataType? elementType, string? raw, string attributeCanonicalName)
|
||
{
|
||
try
|
||
{
|
||
return AttributeValueCodec.Decode(raw, dataType, elementType);
|
||
}
|
||
catch (FormatException ex)
|
||
{
|
||
_logger.LogWarning(ex,
|
||
"Attribute '{Attr}' on '{Instance}' has an undecodable List value; marking Bad quality",
|
||
attributeCanonicalName, _instanceUniqueName);
|
||
return null; // caller marks quality Bad
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
|
||
/// into a typed <c>List<elementClrType></c> matching the attribute's element
|
||
/// type. Each element is converted with invariant culture (round-trip parse for
|
||
/// DateTime). Returns <see langword="false"/> on a missing/invalid element type, a
|
||
/// non-enumerable value, or any element that cannot be coerced — the caller then
|
||
/// marks the attribute quality Bad. Never throws.
|
||
///
|
||
/// Takes the already-parsed <paramref name="elementType"/> — see <see cref="DecodeAttributeValue"/>.
|
||
/// </summary>
|
||
private bool TryCoerceListValue(DataType? elementType, object? incoming, out object? typedList)
|
||
{
|
||
typedList = null;
|
||
|
||
if (elementType is not { } et || !AttributeValueCodec.IsValidElementType(et))
|
||
return false;
|
||
|
||
if (incoming is not System.Collections.IEnumerable enumerable || incoming is string)
|
||
return false;
|
||
|
||
try
|
||
{
|
||
// Coerce INSIDE the try: although the six valid element types cannot
|
||
// throw on construction today, keeping the (shared) codec coercion
|
||
// inside the guarded block means any future change that introduces a
|
||
// throw is caught and turned into a Bad-quality result rather than
|
||
// escaping into the actor and tripping supervision.
|
||
typedList = AttributeValueCodec.CoerceEnumerable(enumerable, et);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Any coercion / construction failure → Bad quality, never a crash.
|
||
_logger.LogWarning(ex,
|
||
"Failed to coerce value to List<{Element}> for instance {Instance}; marking quality Bad",
|
||
et, _instanceUniqueName);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
|
||
{
|
||
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
|
||
qualityChanged.ConnectionName, qualityChanged.Quality, _instanceUniqueName);
|
||
|
||
// Lever 1 (2026-08-28): a connection returning to Good is the Instance Actor's
|
||
// only restore signal (the DCL re-subscribes transparently). If our own subscribe
|
||
// on it is still unconfirmed, retry at the floor now rather than in up to
|
||
// TagSubscribeRetryMaxIntervalMs — recovery must be prompt. Timer != null ⇔ the
|
||
// subscribe is unconfirmed (it is only nulled by a Success); if a request is in
|
||
// flight and young the re-armed tick just hits the guard and re-arms at the floor.
|
||
// Only acts while the interval is actually stretched: once reset to the floor a
|
||
// further Good is a no-op, so a flapping connection cannot keep pushing the armed
|
||
// tick out and starve the retry it was meant to hasten.
|
||
if (qualityChanged.Quality == QualityCode.Good
|
||
&& _tagSubscribeStates.TryGetValue(qualityChanged.ConnectionName, out var subState)
|
||
&& subState.Timer != null
|
||
&& subState.Interval > TimeSpan.FromMilliseconds(_options.TagSubscribeRetryIntervalMs))
|
||
{
|
||
ResetTagSubscribeBackoff(subState);
|
||
ArmTagSubscribeWatchdog(qualityChanged.ConnectionName, subState);
|
||
}
|
||
|
||
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;
|
||
|
||
// Defensive — PreStart is the only caller today.
|
||
// Lever 1: prune retry state from a previous build — cancels any armed watchdog and
|
||
// drops its pending row so a re-entry can never strand an in-flight slot.
|
||
foreach (var state in _tagSubscribeStates.Values)
|
||
{
|
||
state.Timer?.Cancel();
|
||
if (state.InFlightCorrelationId != null)
|
||
_pendingTagSubscribes.Remove(state.InFlightCorrelationId);
|
||
}
|
||
_tagSubscribeStates.Clear();
|
||
|
||
// Group attributes by their bound connection name into the persistent
|
||
// _tagsByConnection field so a retry can re-send the exact same request.
|
||
_tagsByConnection.Clear();
|
||
_tagPathToAttributes.Clear();
|
||
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 (!_tagsByConnection.TryGetValue(attr.BoundDataConnectionName, out var connTags))
|
||
{
|
||
connTags = new List<string>();
|
||
_tagsByConnection[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 (retry-armed).
|
||
foreach (var connectionName in _tagsByConnection.Keys)
|
||
SendTagSubscribe(connectionName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pure backoff step for the tag-subscribe retry: double the
|
||
/// interval just used, apply ±20 % jitter from <paramref name="jitterUnit"/> (a
|
||
/// uniform sample in [0,1]; 0.5 = no jitter) so N instances on one connection do not
|
||
/// re-storm in lockstep, then clamp to [floor, ceiling]. A ceiling below the floor
|
||
/// degrades to a fixed floor interval rather than shrinking below it — same contract
|
||
/// as <c>DataConnectionActor.NextTagResolutionInterval</c>.
|
||
/// </summary>
|
||
/// <param name="current">The interval just used.</param>
|
||
/// <param name="floor">The minimum interval.</param>
|
||
/// <param name="max">The maximum interval; a value below <paramref name="floor"/> degrades to the floor.</param>
|
||
/// <param name="jitterUnit">A uniform sample in [0,1] used to apply jitter; 0.5 means no jitter.</param>
|
||
/// <returns>The next retry interval, clamped to the allowed range.</returns>
|
||
internal static TimeSpan NextTagSubscribeInterval(TimeSpan current, TimeSpan floor, TimeSpan max, double jitterUnit)
|
||
{
|
||
var ceiling = max < floor ? floor : max;
|
||
if (current < floor)
|
||
current = floor;
|
||
|
||
var doubledMs = current.TotalMilliseconds * 2;
|
||
var jittered = doubledMs * (0.8 + 0.4 * Math.Clamp(jitterUnit, 0.0, 1.0));
|
||
var clamped = Math.Clamp(jittered, floor.TotalMilliseconds, ceiling.TotalMilliseconds);
|
||
return TimeSpan.FromMilliseconds(Math.Round(clamped));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sends (or re-sends) the tag-subscribe request for one connection and arms a
|
||
/// per-connection watchdog (S4/UA6). Arming on send means the retry fires whether
|
||
/// the response is a failure OR is lost entirely; a Success reply cancels it.
|
||
/// <para>
|
||
/// Lever 1 (2026-08-28): if a request for this connection is already in flight and
|
||
/// younger than <see cref="SiteRuntimeOptions.TagSubscribeInFlightCeilingMs"/>, no
|
||
/// new request is issued — the watchdog is simply re-armed at the current interval
|
||
/// and the outstanding request keeps its correlation id. This is the guard that
|
||
/// stops a slow gateway from receiving one new N-tag subscribe per interval. Past
|
||
/// the ceiling the reply is presumed lost: the old id is dropped (its late reply, if
|
||
/// any, is ignored as superseded), the interval backs off, and a fresh request goes out.
|
||
/// </para>
|
||
/// </summary>
|
||
private void SendTagSubscribe(string connectionName)
|
||
{
|
||
if (_dclManager == null) return;
|
||
if (!_tagsByConnection.TryGetValue(connectionName, out var tagPaths) || tagPaths.Count == 0)
|
||
return;
|
||
|
||
var state = GetTagSubscribeState(connectionName);
|
||
var now = Environment.TickCount64;
|
||
|
||
if (state.InFlightCorrelationId != null)
|
||
{
|
||
var ageMs = now - state.InFlightSentTicks;
|
||
if (ageMs < _options.TagSubscribeInFlightCeilingMs)
|
||
{
|
||
_logger.LogDebug(
|
||
"Instance {Instance} tag subscribe on connection {Connection} still in flight ({AgeMs}ms, corr={Corr}) — not re-sending",
|
||
_instanceUniqueName, connectionName, ageMs, state.InFlightCorrelationId);
|
||
ArmTagSubscribeWatchdog(connectionName, state);
|
||
return;
|
||
}
|
||
|
||
_logger.LogWarning(
|
||
"Instance {Instance} tag subscribe on connection {Connection} unanswered for {AgeMs}ms (corr={Corr}) — presuming lost, re-sending",
|
||
_instanceUniqueName, connectionName, ageMs, state.InFlightCorrelationId);
|
||
_pendingTagSubscribes.Remove(state.InFlightCorrelationId);
|
||
BackOffTagSubscribe(state);
|
||
}
|
||
|
||
var correlationId = Guid.NewGuid().ToString("N");
|
||
_pendingTagSubscribes[correlationId] = connectionName;
|
||
state.InFlightCorrelationId = correlationId;
|
||
state.InFlightSentTicks = now;
|
||
|
||
var request = new SubscribeTagsRequest(
|
||
correlationId, _instanceUniqueName, connectionName, tagPaths, DateTimeOffset.UtcNow);
|
||
_dclManager.Tell(request, Self);
|
||
_logger.LogInformation(
|
||
"Instance {Instance} subscribing to {Count} tags on connection {Connection} (corr={Corr}, next retry in {Interval})",
|
||
_instanceUniqueName, tagPaths.Count, connectionName, correlationId, state.Interval);
|
||
|
||
ArmTagSubscribeWatchdog(connectionName, state);
|
||
}
|
||
|
||
private TagSubscribeState GetTagSubscribeState(string connectionName)
|
||
{
|
||
if (!_tagSubscribeStates.TryGetValue(connectionName, out var state))
|
||
{
|
||
state = new TagSubscribeState { Interval = TimeSpan.FromMilliseconds(_options.TagSubscribeRetryIntervalMs) };
|
||
_tagSubscribeStates[connectionName] = state;
|
||
}
|
||
return state;
|
||
}
|
||
|
||
/// <summary>(Re)arms the per-connection watchdog at the state's current interval.</summary>
|
||
private void ArmTagSubscribeWatchdog(string connectionName, TagSubscribeState state)
|
||
{
|
||
state.Timer?.Cancel();
|
||
state.Timer = Context.System.Scheduler.ScheduleTellOnceCancelable(
|
||
state.Interval, Self, new RetryTagSubscribe(connectionName), Self);
|
||
}
|
||
|
||
/// <summary>Advances the backoff after a failed or presumed-lost attempt.</summary>
|
||
private void BackOffTagSubscribe(TagSubscribeState state) =>
|
||
state.Interval = NextTagSubscribeInterval(
|
||
state.Interval,
|
||
TimeSpan.FromMilliseconds(_options.TagSubscribeRetryIntervalMs),
|
||
TimeSpan.FromMilliseconds(_options.TagSubscribeRetryMaxIntervalMs),
|
||
Random.Shared.NextDouble());
|
||
|
||
/// <summary>Resets the backoff to its floor (Success, or connection back to Good).</summary>
|
||
private void ResetTagSubscribeBackoff(TagSubscribeState state) =>
|
||
state.Interval = TimeSpan.FromMilliseconds(_options.TagSubscribeRetryIntervalMs);
|
||
|
||
/// <summary>
|
||
/// Handles the DCL's tag-subscribe response. A reply whose correlation id is not the
|
||
/// pending one (superseded at the in-flight ceiling, or a duplicate) is ignored. On
|
||
/// Success the connection's watchdog is cancelled, the in-flight slot cleared and the
|
||
/// backoff reset to the floor. On failure (Lever 1, 2026-08-28) the slot is freed, the
|
||
/// backoff advanced (×2 ±20 % jitter, capped) and the watchdog re-armed at the new
|
||
/// interval, so the next attempt waits the backed-off interval — not the in-flight
|
||
/// ceiling — and duplicates are still impossible.
|
||
/// </summary>
|
||
private void HandleSubscribeTagsResponse(SubscribeTagsResponse response)
|
||
{
|
||
if (!_pendingTagSubscribes.Remove(response.CorrelationId, out var connectionName))
|
||
return; // stale/superseded reply — ignore
|
||
|
||
// Read, never create: the id was minted by SendTagSubscribe, which creates the
|
||
// state, so a missing entry means the retry state was pruned (SubscribeToDcl
|
||
// re-entry) and this reply no longer governs anything.
|
||
if (!_tagSubscribeStates.TryGetValue(connectionName, out var state))
|
||
return;
|
||
|
||
if (response.Success)
|
||
{
|
||
if (state.InFlightCorrelationId == response.CorrelationId)
|
||
{
|
||
state.Timer?.Cancel();
|
||
state.Timer = null;
|
||
state.InFlightCorrelationId = null;
|
||
ResetTagSubscribeBackoff(state);
|
||
}
|
||
_logger.LogDebug(
|
||
"Instance {Instance} tag subscription confirmed on connection {Connection}",
|
||
_instanceUniqueName, connectionName);
|
||
}
|
||
else
|
||
{
|
||
if (state.InFlightCorrelationId == response.CorrelationId)
|
||
{
|
||
// A definite failure: the slot is free, back off, and re-arm at the NEW
|
||
// interval so the backoff is honoured on this very retry, not the one after.
|
||
state.InFlightCorrelationId = null;
|
||
BackOffTagSubscribe(state);
|
||
ArmTagSubscribeWatchdog(connectionName, state);
|
||
}
|
||
_logger.LogWarning(
|
||
"Instance {Instance} tag subscribe FAILED on connection {Connection}: {Error} — will retry in {Interval}",
|
||
_instanceUniqueName, connectionName, response.ErrorMessage, state.Interval);
|
||
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
|
||
"instance_lifecycle", "Warning", _instanceUniqueName,
|
||
$"InstanceActor:{_instanceUniqueName}",
|
||
$"Tag subscribe failed on connection '{connectionName}': {response.ErrorMessage} — will retry in {state.Interval}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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;
|
||
// Retain the full enriched event (Kind, Condition, native metadata) so the
|
||
// DebugView snapshot reflects it — native alarms have no _alarmActors entry.
|
||
_latestAlarmEvents[changed.AlarmName] = changed;
|
||
|
||
// Publish to site-wide stream
|
||
_streamManager?.PublishAlarmStateChanged(changed);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Evicts a native condition's key from the alarm-state maps once
|
||
/// the owning <see cref="NativeAlarmActor"/> has dropped it from its mirror (after
|
||
/// emitting the condition's final return-to-normal). Without this the
|
||
/// <c>_latestAlarmEvents</c> map grows without bound on a source that mints a fresh
|
||
/// <c>SourceReference</c> per occurrence (one permanently-retained Normal entry per
|
||
/// distinct condition the instance has ever seen), leaking per-instance memory and
|
||
/// bloating every DebugView snapshot.
|
||
///
|
||
/// Native-only by construction: the key is a native condition's <c>SourceReference</c>.
|
||
/// Computed-alarm keys (configuration-bounded) are never sent here and never removed.
|
||
/// </summary>
|
||
private void HandleNativeAlarmDropped(NativeAlarmDropped dropped)
|
||
{
|
||
_latestAlarmEvents.Remove(dropped.SourceReference);
|
||
_alarmStates.Remove(dropped.SourceReference);
|
||
_alarmTimestamps.Remove(dropped.SourceReference);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Serves the script-facing alarm snapshot behind <c>Alarms.CurrentAsync()</c>
|
||
/// (MES alarm-status API §5.2). Reuses <see cref="BuildAlarmStatesSnapshot"/>, so a
|
||
/// script sees exactly the alarm set the Debug View shows for the same instant —
|
||
/// computed alarms, mirrored native conditions, and the configured-but-quiet native
|
||
/// binding placeholders (the accessor's caller decides whether to filter those).
|
||
///
|
||
/// <para>
|
||
/// Deliberately does NOT build the attribute list the debug snapshot carries: a script
|
||
/// polling its alarms should not pay to materialise every attribute value. The reply is
|
||
/// a plain mirror of the retained <c>AlarmStateChanged</c> rows; the projection to
|
||
/// <c>ScriptAlarm</c> happens in the accessor, keeping the actor free of script types.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandleGetAlarmSnapshot(GetAlarmSnapshotRequest request)
|
||
{
|
||
Sender.Tell(new GetAlarmSnapshotResponse(
|
||
request.CorrelationId,
|
||
_instanceUniqueName,
|
||
BuildAlarmStatesSnapshot(),
|
||
DateTimeOffset.UtcNow));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 snapshot = new DebugViewSnapshot(
|
||
_instanceUniqueName,
|
||
attributeValues,
|
||
BuildAlarmStatesSnapshot(),
|
||
DateTimeOffset.UtcNow);
|
||
|
||
Sender.Tell(snapshot);
|
||
|
||
_logger.LogDebug(
|
||
"Debug view snapshot sent for {Instance}, correlationId={Id}",
|
||
_instanceUniqueName, request.CorrelationId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds the alarm-state list for a DebugView snapshot. Prefers the latest
|
||
/// enriched <see cref="AlarmStateChanged"/> per alarm (computed alarms that have
|
||
/// fired plus all native alarms) and falls back to a bare Normal projection for
|
||
/// computed alarms that have not yet emitted an event.
|
||
/// </summary>
|
||
private List<AlarmStateChanged> BuildAlarmStatesSnapshot()
|
||
{
|
||
// The MES-reportable flag is stamped HERE and nowhere else (not at the Alarm
|
||
// Actor's emit sites), so the retained-event rows below and the fallback rows
|
||
// that follow always agree on it. A native condition has no _alarmMesReportable
|
||
// entry and is returned as-is (no record copy) — the default false is the intended
|
||
// result, and native rows dominate this list on a busy binding.
|
||
var states = _latestAlarmEvents.Values
|
||
.Select(e => _alarmMesReportable.TryGetValue(e.AlarmName, out var mes) && mes
|
||
? e with { IsMesReportable = true }
|
||
: e)
|
||
.ToList();
|
||
|
||
foreach (var name in _alarmActors.Keys)
|
||
{
|
||
if (_latestAlarmEvents.ContainsKey(name))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
states.Add(new AlarmStateChanged(
|
||
_instanceUniqueName,
|
||
name,
|
||
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
|
||
_alarmPriorities.GetValueOrDefault(name, 0),
|
||
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow))
|
||
{
|
||
IsMesReportable = _alarmMesReportable.GetValueOrDefault(name)
|
||
});
|
||
}
|
||
|
||
// A native binding is "already represented" if any retained event carries its
|
||
// canonical name — it renders in the Debug View via those condition rows. The
|
||
// placeholder below covers ONLY bindings with zero retained events (truly quiet
|
||
// since startup). Do NOT narrow this to State==Active: a binding holding a
|
||
// retained Normal/resolved condition would then be rendered twice.
|
||
var liveBindings = _latestAlarmEvents.Values
|
||
.Where(e => !string.IsNullOrEmpty(e.NativeSourceCanonicalName))
|
||
.Select(e => e.NativeSourceCanonicalName)
|
||
.ToHashSet();
|
||
|
||
foreach (var binding in _nativeAlarmActors.Keys)
|
||
{
|
||
if (liveBindings.Contains(binding)) continue;
|
||
|
||
states.Add(new AlarmStateChanged(
|
||
_instanceUniqueName, binding, AlarmState.Normal, 0,
|
||
_alarmTimestamps.GetValueOrDefault(binding, DateTimeOffset.MinValue))
|
||
{
|
||
Kind = _nativeAlarmKinds.GetValueOrDefault(binding, AlarmKind.NativeOpcUa),
|
||
NativeSourceCanonicalName = binding,
|
||
IsConfiguredPlaceholder = true
|
||
});
|
||
}
|
||
|
||
return states;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Debug view unsubscribe.
|
||
/// 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;
|
||
|
||
// Alarms-only projection: the central live-alarm cache seeds/reconciles
|
||
// through this same query surface and throws every attribute row away. Building
|
||
// and shipping them is pure waste — one instance with a few hundred attributes
|
||
// costs more on the wire than the whole alarm set of the site. The flag is
|
||
// additive and defaults false, so the Debug View is untouched.
|
||
var attributeValues = request.AlarmsOnly
|
||
? new List<AttributeValueChanged>()
|
||
: _attributes.Select(kvp => new AttributeValueChanged(
|
||
_instanceUniqueName,
|
||
kvp.Key,
|
||
kvp.Key,
|
||
kvp.Value,
|
||
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
|
||
_attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();
|
||
|
||
var snapshot = new DebugViewSnapshot(
|
||
_instanceUniqueName,
|
||
attributeValues,
|
||
BuildAlarmStatesSnapshot(),
|
||
DateTimeOffset.UtcNow);
|
||
|
||
Sender.Tell(snapshot);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Publishes attribute change to stream and notifies child Script/Alarm actors.
|
||
/// Tell for attribute notifications (fire-and-forget, never blocks).
|
||
/// </summary>
|
||
/// <param name="changed">The attribute change to publish.</param>
|
||
/// <param name="evaluateWaiters">
|
||
/// WaitForAttribute (spec §4.2): when <c>true</c> (the default), registered
|
||
/// <c>Attributes.WaitAsync</c> waiters on this attribute are re-evaluated against
|
||
/// <paramref name="changed"/>'s value. Pass <c>false</c> on republish/quality-only
|
||
/// paths that do NOT assign a new value to <c>_attributes[name]</c> (e.g. the
|
||
/// List-coerce-failure Bad-quality republish, which publishes the OLD value) —
|
||
/// otherwise an "any-change" waiter (or a waiter whose target equals the unchanged
|
||
/// value) would spuriously fire even though nothing actually changed.
|
||
/// </param>
|
||
private void PublishAndNotifyChildren(AttributeValueChanged changed, bool evaluateWaiters = true)
|
||
{
|
||
// Publish to site-wide stream
|
||
_streamManager?.PublishAttributeValueChanged(changed);
|
||
|
||
// Route the change to exactly the children interested in it (P2): the all-changes
|
||
// bucket (Expression scripts/alarms + fail-open children) plus the children monitoring
|
||
// this specific attribute. Children still gate on their own trigger config — routing
|
||
// is a fan-out optimization, not the correctness check.
|
||
foreach (var child in _allChangeChildren)
|
||
{
|
||
child.Tell(changed);
|
||
}
|
||
if (_childrenByMonitoredAttribute.TryGetValue(changed.AttributeName, out var interested))
|
||
{
|
||
foreach (var child in interested)
|
||
{
|
||
child.Tell(changed);
|
||
}
|
||
}
|
||
|
||
// WaitForAttribute (spec §4.2): re-evaluate any waiters on THIS attribute —
|
||
// but ONLY when this publish reflects a real value change (evaluateWaiters).
|
||
// The genuine value-change paths (HandleAttributeValueChanged, the scalar
|
||
// DCL update path, HandleSetStaticAttributeCore) call it AFTER assigning
|
||
// _attributes[name], so changed.Value is the just-applied current value.
|
||
// Republish/quality-only paths (List-coerce-failure Bad-quality, which
|
||
// publishes the OLD value) pass evaluateWaiters:false so an "any-change" or
|
||
// unchanged-value-equality waiter does not spuriously fire (spec §4.2).
|
||
// Iterate a snapshot so satisfied waiters can be removed during the loop;
|
||
// each match cancels its scheduled timeout (so no stray WaitForAttributeTimeout
|
||
// follows) and replies Matched=true.
|
||
if (evaluateWaiters)
|
||
ResolveMatchedWaiters(changed);
|
||
}
|
||
|
||
/// <summary>
|
||
/// WaitForAttribute (spec §4.2): fires every registered waiter on
|
||
/// <paramref name="changed"/>'s attribute whose test now passes against the
|
||
/// just-applied value — cancelling its timeout, replying Matched, and removing
|
||
/// it from the registry. A no-op when there are no waiters.
|
||
///
|
||
/// <para>
|
||
/// Each waiter's match test runs inside a per-waiter try/catch: a throwing
|
||
/// script-supplied predicate (or codec lambda) must NOT abort the loop and
|
||
/// strand sibling waiters on the same attribute, nor leave the throwing waiter
|
||
/// registered with a live scheduled timeout. On throw we cancel that waiter's
|
||
/// timeout, reply non-matched + ErrorMessage, remove it, and continue.
|
||
/// </para>
|
||
/// </summary>
|
||
private void ResolveMatchedWaiters(AttributeValueChanged changed)
|
||
{
|
||
if (_attributeWaiters.Count == 0)
|
||
return;
|
||
|
||
// Snapshot the candidate waiters on THIS attribute. Iterating a snapshot
|
||
// (and NOT evaluating the test inside the LINQ filter) keeps removal mid-loop
|
||
// safe and ensures one throwing test cannot abort materialization for siblings.
|
||
var candidates = _attributeWaiters
|
||
.Where(kvp => kvp.Value.AttributeName == changed.AttributeName)
|
||
.ToList();
|
||
|
||
foreach (var (cid, pending) in candidates)
|
||
{
|
||
bool matched;
|
||
try
|
||
{
|
||
// §4.2 quality gate ANDed with the value test, both INSIDE the guard:
|
||
// in quality-gated mode a value reaching the target at Bad/Uncertain
|
||
// quality is NOT a match — the waiter stays pending until it satisfies
|
||
// the test at Good quality (or times out).
|
||
matched =
|
||
(!pending.RequireGoodQuality
|
||
|| string.Equals(changed.Quality, "Good", StringComparison.Ordinal))
|
||
&& pending.Test(changed.Value);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex,
|
||
"WaitForAttribute predicate threw while resolving waiter {CorrelationId} on {Instance}.{Attribute}; evicting it",
|
||
cid, _instanceUniqueName, changed.AttributeName);
|
||
pending.Timeout.Cancel();
|
||
pending.Replyer.Tell(new WaitForAttributeResponse(
|
||
cid, Matched: false, null, null, TimedOut: false,
|
||
ErrorMessage: "Wait predicate threw: " + ex.Message));
|
||
_attributeWaiters.Remove(cid);
|
||
continue;
|
||
}
|
||
|
||
if (!matched)
|
||
continue;
|
||
|
||
pending.Timeout.Cancel();
|
||
pending.Replyer.Tell(new WaitForAttributeResponse(
|
||
cid, Matched: true, changed.Value, changed.Quality, TimedOut: false));
|
||
_attributeWaiters.Remove(cid);
|
||
}
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
// Persisted override values are canonical strings — a JSON array
|
||
// string for List attributes, a plain string for scalars. Decode List
|
||
// overrides to a typed list (matching the config-default load), set
|
||
// Bad quality on a malformed stored value, and never crash the actor.
|
||
if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
|
||
&& resolved.IsList)
|
||
{
|
||
// Decode the stored List override (both old array-of-strings
|
||
// and native-typed forms decode) and re-persist the native form if
|
||
// the stored value is still in the OLD form. Re-encoding the decoded
|
||
// list and comparing to the stored string detects old-form values
|
||
// (native → native is byte-identical, so a native value is a no-op).
|
||
// The re-persist is fire-and-forget and never throws into the actor.
|
||
var decoded = DecodeAttributeValue(
|
||
resolved.DataType, resolved.ElementDataType, kvp.Value, kvp.Key);
|
||
_attributes[kvp.Key] = decoded;
|
||
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
|
||
{
|
||
_attributeQualities[kvp.Key] = "Bad";
|
||
}
|
||
else if (decoded is not null)
|
||
{
|
||
var native = AttributeValueCodec.Encode(decoded);
|
||
if (native != kvp.Value) // stored value was old-form → normalize on disk
|
||
{
|
||
var key = kvp.Key;
|
||
var logger = _logger;
|
||
var instanceName = _instanceUniqueName;
|
||
_storage.SetStaticOverrideAsync(instanceName, key, native!)
|
||
.ContinueWith(t => logger.LogWarning(t.Exception?.GetBaseException(),
|
||
"Failed to normalize static override {Instance}.{Attr} to native JSON",
|
||
instanceName, key),
|
||
TaskContinuationOptions.OnlyOnFaulted);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_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.
|
||
/// Script Actors spawned per script definition.
|
||
/// Alarm Actors spawned per alarm definition, as peers to Script Actors.
|
||
/// Compile failures are rejected at deploy time by the Deployment Manager's
|
||
/// validation gate; a failure reaching this point (startup load of a legacy
|
||
/// config) is logged and the offending script/alarm skipped.
|
||
///
|
||
/// 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>
|
||
/// <summary>
|
||
/// Files a freshly-created child actor into the attribute-change routing maps (P2)
|
||
/// according to its trigger classification. Native alarm actors are NOT routed here —
|
||
/// they subscribe to their own native feed through the DCL and never react to computed
|
||
/// attribute changes.
|
||
/// </summary>
|
||
private void RouteChild(IActorRef child, TriggerRoutingDecision decision)
|
||
{
|
||
switch (decision.Kind)
|
||
{
|
||
case TriggerRoutingKind.MonitoredAttribute:
|
||
if (!_childrenByMonitoredAttribute.TryGetValue(decision.MonitoredAttribute!, out var list))
|
||
{
|
||
list = new List<IActorRef>();
|
||
_childrenByMonitoredAttribute[decision.MonitoredAttribute!] = list;
|
||
}
|
||
list.Add(child);
|
||
break;
|
||
case TriggerRoutingKind.AllChanges:
|
||
_allChangeChildren.Add(child);
|
||
break;
|
||
case TriggerRoutingKind.None:
|
||
// Interval / call-only triggers never react to attribute changes.
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void CreateChildActors()
|
||
{
|
||
if (_configuration == null) return;
|
||
|
||
// 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;
|
||
RouteChild(actorRef, TriggerRouting.ForScript(script.TriggerType, script.TriggerConfiguration));
|
||
}
|
||
|
||
// Create Alarm Actors
|
||
foreach (var alarm in _configuration.Alarms)
|
||
{
|
||
Script<object?>? onTriggerScript = null;
|
||
// The on-trigger script's per-script execution timeout,
|
||
// captured from its ResolvedScript so the launched on-trigger run
|
||
// can apply perScript ?? global. Null when there is no on-trigger script.
|
||
int? onTriggerTimeoutSeconds = 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)
|
||
{
|
||
onTriggerTimeoutSeconds = triggerScriptDef.ExecutionTimeoutSeconds;
|
||
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,
|
||
_serviceProvider,
|
||
// Per-script timeout for the alarm on-trigger script.
|
||
onTriggerTimeoutSeconds));
|
||
|
||
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
||
_alarmActors[alarm.CanonicalName] = actorRef;
|
||
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
|
||
_alarmMesReportable[alarm.CanonicalName] = alarm.IsMesReportable;
|
||
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
|
||
RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration));
|
||
}
|
||
|
||
// Create Native Alarm Actors — read-only mirror of each bound source's
|
||
// native alarms (peers to the computed Alarm Actors). They subscribe through
|
||
// the DCL manager, so without one (e.g. in isolated tests) they are skipped.
|
||
foreach (var nativeSource in _configuration.NativeAlarmSources)
|
||
{
|
||
if (_dclManager == null)
|
||
{
|
||
_logger.LogWarning(
|
||
"Instance {Instance}: native alarm source {Source} skipped — no DCL manager available",
|
||
_instanceUniqueName, nativeSource.CanonicalName);
|
||
continue;
|
||
}
|
||
|
||
var nativeKind = ResolveNativeKind(nativeSource.ConnectionName);
|
||
var props = Props.Create(() => new NativeAlarmActor(
|
||
nativeSource,
|
||
_instanceUniqueName,
|
||
Self,
|
||
_dclManager,
|
||
_storage,
|
||
_options,
|
||
_logger,
|
||
nativeKind,
|
||
_serviceProvider));
|
||
|
||
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
|
||
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
|
||
// Same kind handed to the NativeAlarmActor — used to stamp the
|
||
// (cosmetic) kind badge on placeholder rows for quiet bindings.
|
||
_nativeAlarmKinds[nativeSource.CanonicalName] = nativeKind;
|
||
}
|
||
|
||
_logger.LogInformation(
|
||
"Instance {Instance}: created {Scripts} script actors, {Alarms} alarm actors, {NativeAlarms} native alarm actors",
|
||
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count, _nativeAlarmActors.Count);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Maps a bound connection's protocol to the native alarm kind stamped on
|
||
/// emitted events. MxAccess Gateway connections yield
|
||
/// <see cref="AlarmKind.NativeMxAccess"/>; everything else (OPC UA) yields
|
||
/// <see cref="AlarmKind.NativeOpcUa"/>.
|
||
/// </summary>
|
||
private AlarmKind ResolveNativeKind(string connectionName)
|
||
{
|
||
var protocol = _configuration?.Connections is { } connections
|
||
&& connections.TryGetValue(connectionName, out var cfg)
|
||
? cfg.Protocol
|
||
: null;
|
||
|
||
return protocol != null && protocol.Contains("Mx", StringComparison.OrdinalIgnoreCase)
|
||
? AlarmKind.NativeMxAccess
|
||
: AlarmKind.NativeOpcUa;
|
||
}
|
||
|
||
/// <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>
|
||
/// Read-only view of the per-attribute child routing map (P2, for testing/diagnostics).
|
||
/// </summary>
|
||
internal IReadOnlyDictionary<string, List<IActorRef>> ChildrenByMonitoredAttribute => _childrenByMonitoredAttribute;
|
||
|
||
/// <summary>
|
||
/// Read-only view of the all-changes child routing bucket (P2, for testing/diagnostics).
|
||
/// </summary>
|
||
internal IReadOnlyList<IActorRef> AllChangeChildren => _allChangeChildren;
|
||
|
||
/// <summary>
|
||
/// Internal message for async override loading result.
|
||
/// </summary>
|
||
internal record LoadOverridesResult(Dictionary<string, string> Overrides, string? Error);
|
||
|
||
/// <summary>
|
||
/// WaitForAttribute (spec §4.2): one registered, not-yet-satisfied waiter.
|
||
/// </summary>
|
||
/// <param name="AttributeName">The attribute this waiter watches (scope-resolved).</param>
|
||
/// <param name="Test">The match test (decoded-target equality OR site-local predicate OR any-change).</param>
|
||
/// <param name="Replyer">The original sender to reply to on match / timeout.</param>
|
||
/// <param name="Timeout">The scheduled timeout handle, canceled on match.</param>
|
||
/// <param name="RequireGoodQuality">
|
||
/// Quality-gated ("Good"-only) mode (spec §4.2): when <c>true</c>, the resolve
|
||
/// loop additionally requires <c>changed.Quality == "Good"</c> before the test
|
||
/// can match.
|
||
/// </param>
|
||
private sealed record PendingWait(
|
||
string AttributeName,
|
||
Func<object?, bool> Test,
|
||
IActorRef Replyer,
|
||
ICancelable Timeout,
|
||
bool RequireGoodQuality);
|
||
|
||
/// <summary>
|
||
/// Self-scheduled tick (S4/UA6): re-send the tag-subscribe request for one
|
||
/// connection whose previous attempt failed or whose response was lost.
|
||
/// </summary>
|
||
private sealed record RetryTagSubscribe(string ConnectionName);
|
||
|
||
/// <summary>
|
||
/// Self-message (arch-review F6): a traced data write FAILED, so drop its trace arm — but only
|
||
/// if it is still the armed one. The write's continuation runs off the dispatcher and must not
|
||
/// touch <see cref="_traceArms"/> itself; matching on <paramref name="SourceExecutionId"/> keeps
|
||
/// a late failure from stealing an arm a newer traced write has since taken.
|
||
/// </summary>
|
||
/// <param name="AttributeName">Canonical name of the attribute whose arm is to be dropped.</param>
|
||
/// <param name="SourceExecutionId">Execution id of the writer that took the arm.</param>
|
||
private sealed record DisarmTrace(string AttributeName, Guid SourceExecutionId);
|
||
|
||
/// <summary>Self-tell that fires the coalesced static-override persist flush.</summary>
|
||
private sealed class FlushStaticOverrides
|
||
{
|
||
public static readonly FlushStaticOverrides Instance = new();
|
||
private FlushStaticOverrides() { }
|
||
}
|
||
}
|