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.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.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;
///
/// Represents a single deployed instance at runtime. Holds the in-memory attribute state
/// (loaded from FlattenedConfiguration + static overrides from SQLite).
///
/// The Instance Actor is the single source of truth for runtime instance state.
/// WP-24: All state mutations are serialized through the actor mailbox.
/// Multiple Script Execution Actors run concurrently; state mutations through this actor.
///
/// WP-15/16: Creates child Script Actors and Alarm Actors on startup.
/// WP-22: Tell for tag value updates, attribute notifications, stream publishing.
/// Ask for CallScript, debug snapshot.
/// WP-25: Debug view backend — snapshot + stream subscription.
///
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 _attributes = new();
private readonly Dictionary _attributeQualities = new();
private readonly Dictionary _attributeTimestamps = new();
private readonly Dictionary _alarmStates = new();
private readonly Dictionary _alarmTimestamps = new();
private readonly Dictionary _alarmPriorities = new();
private readonly Dictionary _scriptActors = new();
private readonly Dictionary _alarmActors = new();
private readonly Dictionary _nativeAlarmActors = new();
///
/// Latest enriched per alarm name (computed and
/// native), so the DebugView snapshot carries the unified condition + native
/// metadata rather than a bare State/Priority projection.
///
private readonly Dictionary _latestAlarmEvents = new();
private FlattenedConfiguration? _configuration;
// MV-8: 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).
private readonly Dictionary _resolvedAttributeByName = new();
// 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 _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;
// 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> _tagPathToAttributes = new();
///
/// Initializes the instance actor with its configuration and dependencies.
///
/// System-wide unique name identifying this instance.
/// JSON-serialized flattened configuration for this instance.
/// Site storage service for loading and persisting static overrides.
/// Service used to compile instance scripts.
/// Library of shared scripts available to instance scripts.
/// Optional site stream manager for publishing attribute/alarm changes.
/// Site runtime configuration options.
/// Logger for this actor.
/// Optional Data Connection Layer manager actor reference.
/// Optional health collector for reporting metrics.
/// Optional DI service provider for script execution services.
public InstanceActor(
string instanceUniqueName,
string configJson,
SiteStorageService storage,
ScriptCompilationService compilationService,
SharedScriptLibrary sharedScriptLibrary,
SiteStreamManager? streamManager,
SiteRuntimeOptions options,
ILogger logger,
IActorRef? dclManager = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
{
_instanceUniqueName = instanceUniqueName;
_storage = storage;
_compilationService = compilationService;
_sharedScriptLibrary = sharedScriptLibrary;
_streamManager = streamManager;
_options = options;
_logger = logger;
_dclManager = dclManager;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
// Deserialize the flattened configuration
_configuration = JsonSerializer.Deserialize(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)
{
// MV-8: index resolved attributes for O(1) lookup on the hot
// TagValueUpdate ingest path (last-wins on duplicate names).
_resolvedAttributeByName[attr.CanonicalName] = attr;
// MV-7: a STATIC List attribute's default is the canonical JSON
// array string. Decode it to a typed List 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 (IsListAttribute(attr))
{
var decoded = DecodeAttributeValue(attr, attr.Value);
_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(HandleGetAttribute);
// Handle static attribute writes
Receive(HandleSetStaticAttribute);
// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the
// Deployment Manager — DeploymentManagerActor.HandleDisable/HandleEnable
// stop or re-create the Instance Actor directly and reply to the caller.
// DisableInstanceCommand / EnableInstanceCommand are never routed to the
// Instance Actor, so no handlers are registered here. (The previous no-op
// handlers were dead code that implied a non-existent instance-side
// acknowledgement contract.)
// WP-15: Handle script call requests — route to appropriate Script Actor (Ask pattern)
Receive(HandleScriptCallRequest);
// WP-22/23: Handle attribute value changes from DCL (Tell pattern)
Receive(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(HandleWaitForAttribute);
Receive(HandleWaitForAttributeTimeout);
// Handle tag value updates from DCL — convert to AttributeValueChanged
Receive(HandleTagValueUpdate);
Receive(_ => { }); // Ack from DCL subscribe — no action needed
Receive(HandleConnectionQualityChanged);
// WP-16: Handle alarm state changes from Alarm Actors (Tell pattern)
Receive(HandleAlarmStateChanged);
// WP-25: Debug view subscribe/unsubscribe (Ask pattern for snapshot)
Receive(HandleSubscribeDebugView);
Receive(HandleUnsubscribeDebugView);
// Debug snapshot (one-shot, no subscription)
Receive(HandleDebugSnapshot);
// Handle internal messages
Receive(HandleOverridesLoaded);
}
///
protected override void PreStart()
{
base.PreStart();
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
// M1.6: 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
var self = Self;
_storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return new LoadOverridesResult(t.Result, null);
return new LoadOverridesResult(new Dictionary(), t.Exception?.GetBaseException().Message);
}).PipeTo(self);
// Create child Script Actors and Alarm Actors from configuration
CreateChildActors();
// Subscribe to DCL for data-sourced attributes
SubscribeToDcl();
}
///
protected override void PostStop()
{
// M1.6: 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();
}
///
/// M1.6: fire-and-forget an instance_lifecycle operational event to the
/// optional . Resolved optionally and never
/// awaited so a logging failure cannot affect the instance lifecycle
/// (matching the established ScriptActor/ScriptExecutionActor pattern).
///
private void LogLifecycleEvent(string message)
{
_ = _serviceProvider?.GetService()?.LogEventAsync(
"instance_lifecycle", "Info", _instanceUniqueName,
$"InstanceActor:{_instanceUniqueName}", message);
}
///
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;
}));
}
///
/// Returns the current attribute value. Uses Tell pattern; sender gets the response.
///
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));
}
///
/// Handles an attribute write (Instance.SetAttribute / Inbound API).
/// WP-24: State mutation serialized through this actor's mailbox.
///
/// The write is routed by the attribute's data binding:
/// * Data-sourced attribute → forwards a 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 .
///
private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
{
// Resolve the target attribute's data binding from the flattened config.
var resolved = _configuration?.Attributes
.FirstOrDefault(a => a.CanonicalName == command.AttributeName);
// SiteRuntime-025: reject writes targeting an attribute that does not exist
// on the deployed instance. Without this check, an inbound API
// SetAttribute("notARealAttr", ...) would pollute the in-memory
// _attributes dictionary, publish a synthetic AttributeValueChanged to
// debug-view subscribers, and persist a durable static-override row that
// resurrects on every restart. The override row is also outside the
// ClearStaticOverridesAsync window for unknown names. Refuse the write
// and let the caller see the failure, mirroring the script trust model's
// "scripts can only read/write attributes on their own instance" framing.
if (resolved == null)
{
_logger.LogWarning(
"SetAttribute rejected — attribute '{Attribute}' is not defined on instance '{Instance}'",
command.AttributeName, _instanceUniqueName);
Sender.Tell(new SetStaticAttributeResponse(
command.CorrelationId,
_instanceUniqueName,
command.AttributeName,
false,
$"Unknown attribute '{command.AttributeName}'",
DateTimeOffset.UtcNow));
return;
}
var isDataSourced =
!string.IsNullOrEmpty(resolved.DataSourceReference)
&& !string.IsNullOrEmpty(resolved.BoundDataConnectionName);
if (isDataSourced)
{
HandleSetDataAttribute(command, resolved);
return;
}
HandleSetStaticAttributeCore(command);
}
///
/// Static attribute write: updates in-memory state, publishes the change,
/// persists the override to SQLite, and replies with success.
///
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
{
// MV-7: 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)
&& IsListAttribute(resolved))
{
// MV-7: 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, command.Value);
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 (WP-23) and notify children
var changed = new AttributeValueChanged(
_instanceUniqueName,
command.AttributeName,
command.AttributeName,
command.Value,
"Good",
DateTimeOffset.UtcNow);
PublishAndNotifyChildren(changed);
// Persist asynchronously -- fire and forget since the actor is the source of truth.
var instanceName = _instanceUniqueName;
var attributeName = command.AttributeName;
var logger = _logger;
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
.ContinueWith(t =>
{
logger.LogWarning(
t.Exception?.GetBaseException(),
"Failed to persist static override for {Instance}.{Attribute}; in-memory state is authoritative",
instanceName,
attributeName);
}, TaskContinuationOptions.OnlyOnFaulted);
Sender.Tell(new SetStaticAttributeResponse(
command.CorrelationId, _instanceUniqueName, command.AttributeName,
true, null, DateTimeOffset.UtcNow));
}
///
/// 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.
///
private void HandleSetDataAttribute(SetStaticAttributeCommand command, ResolvedAttribute resolved)
{
var caller = Sender;
var correlationId = command.CorrelationId;
var attributeName = command.AttributeName;
var instanceName = _instanceUniqueName;
if (_dclManager == null)
{
_logger.LogWarning(
"SetAttribute on data-sourced attribute {Instance}.{Attribute} cannot be routed — no DCL manager configured",
instanceName, attributeName);
caller.Tell(new SetStaticAttributeResponse(
correlationId, instanceName, attributeName, false,
"Data Connection Layer not available for write.", DateTimeOffset.UtcNow));
return;
}
// MV (C1): for a data-sourced List attribute the incoming command.Value is
// the canonical JSON array string (ScopeAccessors encodes the script's
// List 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 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 (IsListAttribute(resolved) && !string.IsNullOrWhiteSpace(command.Value))
{
var decoded = DecodeAttributeValue(resolved, command.Value);
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.BoundDataConnectionName!,
resolved.DataSourceReference!,
writeValue,
DateTimeOffset.UtcNow);
// Ask the DCL and pipe the result back to the original caller. The DCL
// returns the failure synchronously so the script can handle it.
_dclManager.Ask(writeRequest, TimeSpan.FromSeconds(30))
.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
return new SetStaticAttributeResponse(
correlationId, instanceName, attributeName,
t.Result.Success, t.Result.ErrorMessage, DateTimeOffset.UtcNow);
return new SetStaticAttributeResponse(
correlationId, instanceName, attributeName, false,
t.Exception?.GetBaseException().Message ?? "DCL write timed out",
DateTimeOffset.UtcNow);
}).PipeTo(caller);
}
///
/// WP-15: Routes script call requests to the appropriate Script Actor.
/// Uses Ask pattern (WP-22).
///
private void HandleScriptCallRequest(ScriptCallRequest request)
{
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
{
// Forward the request to the Script Actor, preserving the original
// sender. The whole record is forwarded unchanged, so any
// ParentExecutionId (Audit Log #23) set by an inbound-API-routed
// call is carried through to the Script Actor verbatim.
scriptActor.Forward(request);
}
else
{
Sender.Tell(new ScriptCallResult(
request.CorrelationId,
false,
null,
$"Script '{request.ScriptName}' not found on instance '{_instanceUniqueName}'."));
}
}
///
/// WP-22/23: Handles attribute value changes from DCL or static writes.
/// Updates in-memory state, publishes to stream, and notifies children.
///
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// WP-24: State mutation serialized through this actor
_attributes[changed.AttributeName] = changed.Value;
_attributeQualities[changed.AttributeName] = changed.Quality;
_attributeTimestamps[changed.AttributeName] = changed.Timestamp;
PublishAndNotifyChildren(changed);
}
///
/// 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 both run on
/// this single-threaded actor, so a value that flips between "read current"
/// and "register" cannot be missed (spec §5).
///
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