Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
T
Joseph Doherty 0ffd4bdbea docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)
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.
2026-10-07 14:12:25 -04:00

2165 lines
105 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&lt;T&gt;</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&lt;elementClrType&gt;</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() { }
}
}