docs(comments): strip internal task/milestone/bundle bookkeeping from code comments

Remove project bookkeeping citations from shipped code comments across the
solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/
issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels,
and C/D/K/S/T phase labels.

Comment text only — no code logic, string/log literals, or XML-doc structure
changed. Genuine descriptions are preserved (only the citation is stripped),
and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365,
UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker
TaskReferenceInComment / TrackingReferenceInComment checks plus targeted
grep passes; full solution builds clean, append-only guard tests pass.
This commit is contained in:
Joseph Doherty
2026-07-07 11:03:26 -04:00
parent 67005ca4c0
commit 9cff87fe85
435 changed files with 2338 additions and 2547 deletions
@@ -14,7 +14,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-16: Alarm Actor — coordinator actor, child of Instance Actor, peer to Script Actors.
/// Alarm Actor — coordinator actor, child of Instance Actor, peer to Script Actors.
/// Subscribes to attribute change notifications from Instance Actor.
///
/// Evaluates alarm conditions:
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// State (active/normal) is in memory only, NOT persisted.
/// On restart: starts normal, re-evaluates from incoming values.
///
/// WP-21: AlarmExecutionActor CAN call Instance.CallScript() (ask to sibling Script Actor).
/// AlarmExecutionActor CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no Instance.CallAlarmScript API).
///
/// Supervision: Resume on exception; AlarmExecutionActor stopped on exception.
@@ -42,7 +42,7 @@ public class AlarmActor : ReceiveActor
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// M1.5: the optional site operational-event log, resolved once from
/// The optional site operational-event log, resolved once from
/// <see cref="_serviceProvider"/> at construction and cached. The
/// registration is process-lifetime (a singleton), so resolving once on
/// the actor's own thread is both correct and cheaper than a per-event
@@ -53,7 +53,7 @@ public class AlarmActor : ReceiveActor
private readonly ISiteEventLogger? _siteEventLogger;
/// <summary>
/// M1.5: priority at or above which a computed-alarm raise is logged as
/// Priority at or above which a computed-alarm raise is logged as
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
/// Mirrors the 01000 alarm-severity scale.
/// </summary>
@@ -73,7 +73,7 @@ public class AlarmActor : ReceiveActor
private readonly Script<object?>? _onTriggerCompiledScript;
/// <summary>
/// M2.5 (#9): the on-trigger script's per-script execution timeout in seconds,
/// The on-trigger script's per-script execution timeout in seconds,
/// or null to use the global default. Forwarded to each spawned
/// <see cref="AlarmExecutionActor"/>, which applies <c>perScript ?? global</c>
/// (treating ≤ 0 as "use global"). The value comes from the referenced
@@ -88,7 +88,7 @@ public class AlarmActor : ReceiveActor
private readonly Dictionary<string, object?> _attributeSnapshot = new();
/// <summary>
/// SiteRuntime-017: the exact dictionary instance this actor was seeded from
/// The exact dictionary instance this actor was seeded from
/// at construction. The Instance Actor must pass a private snapshot here, not
/// its live <c>_attributes</c> field. Exposed for regression coverage of that
/// isolation contract.
@@ -114,9 +114,9 @@ public class AlarmActor : ReceiveActor
/// <param name="initialAttributes">Seed attribute snapshot so static attributes evaluate correctly at startup.</param>
/// <param name="healthCollector">Optional health collector for surfacing alarm execution metrics.</param>
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
/// <see cref="ISiteEventLogger"/> for M1.5 <c>alarm</c> operational events. Fire-and-forget;
/// <see cref="ISiteEventLogger"/> for <c>alarm</c> operational events. Fire-and-forget;
/// a logging failure never affects alarm evaluation.</param>
/// <param name="onTriggerExecutionTimeoutSeconds">M2.5 (#9): the on-trigger script's per-script
/// <param name="onTriggerExecutionTimeoutSeconds">The on-trigger script's per-script
/// execution timeout in seconds (from its <see cref="ResolvedScript.ExecutionTimeoutSeconds"/>),
/// or null/non-positive to use the global default.</param>
public AlarmActor(
@@ -132,7 +132,7 @@ public class AlarmActor : ReceiveActor
IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
// M2.5 (#9): per-script timeout for the on-trigger script (null = global).
// Per-script timeout for the on-trigger script (null = global).
int? onTriggerExecutionTimeoutSeconds = null)
{
_alarmName = alarmName;
@@ -143,7 +143,7 @@ public class AlarmActor : ReceiveActor
_logger = logger;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
// M1.5: resolve the optional site event logger once and cache it,
// Resolve the optional site event logger once and cache it,
// rather than calling GetService on every alarm transition.
_siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
_priority = alarmConfig.PriorityLevel;
@@ -252,7 +252,7 @@ public class AlarmActor : ReceiveActor
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// M1.5: operational `alarm` event — raise. Severity by priority.
// Operational `alarm` event — raise. Severity by priority.
LogAlarmEvent(RaiseSeverity(_priority), $"Alarm {_alarmName} activated (priority {_priority})");
// Spawn AlarmExecutionActor if on-trigger script defined
@@ -273,7 +273,7 @@ public class AlarmActor : ReceiveActor
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
// M1.5: operational `alarm` event — return to normal.
// Operational `alarm` event — return to normal.
LogAlarmEvent("Info", $"Alarm {_alarmName} cleared");
}
}
@@ -315,7 +315,7 @@ public class AlarmActor : ReceiveActor
};
_instanceActor.Tell(alarmChanged);
// M1.5: operational `alarm` event. Entering a band from Normal is a raise
// Operational `alarm` event. Entering a band from Normal is a raise
// (severity by the band's priority); returning to None is a clear; a
// level-to-level escalation/de-escalation is an informational transition.
if (newLevel == AlarmLevel.None)
@@ -342,7 +342,7 @@ public class AlarmActor : ReceiveActor
}
/// <summary>
/// M1.5: maps an alarm priority (01000) to a site-event severity for a
/// Maps an alarm priority (01000) to a site-event severity for a
/// <i>raise</i> transition — <c>Error</c> at or above
/// <see cref="ErrorPriorityThreshold"/>, otherwise <c>Warning</c>. Clears and
/// inter-band transitions always log as <c>Info</c>.
@@ -351,7 +351,7 @@ public class AlarmActor : ReceiveActor
priority >= ErrorPriorityThreshold ? "Error" : "Warning";
/// <summary>
/// M1.5: fire-and-forget an <c>alarm</c> operational event to the optional
/// Fire-and-forget an <c>alarm</c> operational event to the optional
/// <see cref="ISiteEventLogger"/> (resolved once at construction and cached
/// in <see cref="_siteEventLogger"/>). Never awaited so a logging failure
/// cannot affect alarm evaluation (matching the established
@@ -429,7 +429,7 @@ public class AlarmActor : ReceiveActor
try
{
// InvariantCulture so string attribute values parse consistently
// regardless of host locale (SiteRuntime-023).
// regardless of host locale.
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
return numericValue < config.Min || numericValue > config.Max;
}
@@ -447,7 +447,7 @@ public class AlarmActor : ReceiveActor
try
{
// InvariantCulture so string attribute values parse consistently
// regardless of host locale (SiteRuntime-023).
// regardless of host locale.
var numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
// Add to window
@@ -537,7 +537,7 @@ public class AlarmActor : ReceiveActor
double numericValue;
// InvariantCulture so string attribute values parse consistently
// regardless of host locale (SiteRuntime-023).
// regardless of host locale.
try { numericValue = Convert.ToDouble(value, CultureInfo.InvariantCulture); }
catch { return _currentLevel; }
@@ -575,7 +575,7 @@ public class AlarmActor : ReceiveActor
/// <param name="priority">The firing alarm priority.</param>
/// <param name="message">The firing alarm message.</param>
/// <param name="parentExecutionId">
/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the execution id of
/// The execution id of
/// the context that fired this alarm, recorded as the on-trigger script run's
/// <c>ParentExecutionId</c> so the alarm-triggered run chains under its firing
/// context in the audit tree. The alarm subsystem currently has no Guid-typed
@@ -590,7 +590,7 @@ public class AlarmActor : ReceiveActor
var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}";
// SiteRuntime-009: the on-trigger script body runs on the dedicated
// The on-trigger script body runs on the dedicated
// ScriptExecutionScheduler, not the shared .NET thread pool.
var props = Props.Create(() => new AlarmExecutionActor(
_alarmName,
@@ -603,9 +603,9 @@ public class AlarmActor : ReceiveActor
_sharedScriptLibrary,
_options,
_logger,
// M2.5 (#9): per-script timeout from the on-trigger script (null = global).
// Per-script timeout from the on-trigger script (null = global).
_onTriggerExecutionTimeoutSeconds,
// Audit Log #23 (M5.4): the firing context's execution id (null today).
// The firing context's execution id (null today).
parentExecutionId));
Context.ActorOf(props, executionId);
@@ -9,9 +9,9 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-16: Alarm Execution Actor -- short-lived child of Alarm Actor.
/// Alarm Execution Actor -- short-lived child of Alarm Actor.
/// Same pattern as ScriptExecutionActor.
/// WP-21: CAN call Instance.CallScript() (ask to sibling Script Actor).
/// CAN call Instance.CallScript() (ask to sibling Script Actor).
/// Instance scripts CANNOT call alarm on-trigger scripts (no API for it).
/// Supervision: Stop on unhandled exception.
/// </summary>
@@ -28,9 +28,9 @@ public class AlarmExecutionActor : ReceiveActor
/// <param name="sharedScriptLibrary">Shared script library providing common utilities.</param>
/// <param name="options">Site runtime configuration options, including the execution timeout.</param>
/// <param name="logger">Logger for execution diagnostics.</param>
/// <param name="executionTimeoutSeconds">M2.5 (#9): the on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
/// <param name="executionTimeoutSeconds">The on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
/// <param name="parentExecutionId">
/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the execution id of
/// ParentExecutionId tag-cascade: the execution id of
/// the context that fired this alarm, threaded into the on-trigger script's
/// <see cref="ScriptRuntimeContext"/> as its <c>ParentExecutionId</c> so the
/// alarm-triggered run chains under its firing context. Null today (no
@@ -48,10 +48,10 @@ public class AlarmExecutionActor : ReceiveActor
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger,
// M2.5 (#9): per-script execution timeout override (seconds) for the
// Per-script execution timeout override (seconds) for the
// alarm on-trigger script. Null or non-positive falls back to the global.
int? executionTimeoutSeconds = null,
// Audit Log #23 (M5.4): the firing context's execution id (null today).
// The firing context's execution id (null today).
Guid? parentExecutionId = null)
{
var self = Self;
@@ -80,14 +80,14 @@ public class AlarmExecutionActor : ReceiveActor
int? executionTimeoutSeconds,
Guid? parentExecutionId)
{
// M2.5 (#9): per-script timeout overrides the global default. A null or
// Per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
var timeout = TimeSpan.FromSeconds(
executionTimeoutSeconds is { } perScript && perScript > 0
? perScript
: options.ScriptExecutionTimeoutSeconds);
// SiteRuntime-009: run the alarm on-trigger body on the dedicated
// Run the alarm on-trigger body on the dedicated
// script-execution scheduler, not the shared .NET thread pool.
var scheduler = ScriptExecutionScheduler.Shared(options);
@@ -96,7 +96,7 @@ public class AlarmExecutionActor : ReceiveActor
using var cts = new CancellationTokenSource(timeout);
try
{
// WP-21: AlarmExecutionActor can call Instance.CallScript()
// AlarmExecutionActor can call Instance.CallScript()
// via the ScriptRuntimeContext injected into globals
var context = new ScriptRuntimeContext(
instanceActor,
@@ -107,7 +107,7 @@ public class AlarmExecutionActor : ReceiveActor
timeout,
instanceName,
logger,
// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the
// ParentExecutionId tag-cascade: the
// alarm on-trigger run mints its own fresh ExecutionId (the
// ctor's `?? NewGuid()` fallback) and records the firing
// context's id as its ParentExecutionId — null today, so the
@@ -147,7 +147,7 @@ public class AlarmExecutionActor : ReceiveActor
}
catch (Exception ex)
{
// WP-32: Failures logged, alarm continues
// Failures logged, alarm continues
logger.LogError(ex,
"Alarm on-trigger script for {Alarm} on {Instance} failed",
alarmName, instanceName);
@@ -7,13 +7,13 @@ using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Per-node OPC UA certificate-store actor (T17 / D6). Runs on EVERY site
/// Per-node OPC UA certificate-store actor. Runs on EVERY site
/// node (NOT a singleton) at a well-known name so the Deployment Manager
/// singleton can address it on each node via <see cref="ActorSelection"/>.
///
/// Cert trust is site-local: the trusted-peer PKI store is a directory on each
/// node's file system. A trust/remove decision must therefore reach BOTH site
/// nodes or the two stores diverge across failover (D6). The singleton fans the
/// nodes or the two stores diverge across failover. The singleton fans the
/// per-node <see cref="WriteCertToLocalStore"/> / <see cref="RemoveCertFromLocalStore"/>
/// message out; this actor performs the actual file write/delete/enumerate.
///
@@ -40,8 +40,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly SiteRuntimeOptions _options;
private readonly ILogger<DeploymentManagerActor> _logger;
/// <summary>
/// Shared logger factory used to mint <see cref="InstanceActor"/> loggers
/// (SiteRuntime-015). Reused across every <see cref="CreateInstanceActor"/>
/// Shared logger factory used to mint <see cref="InstanceActor"/> loggers.
/// Reused across every <see cref="CreateInstanceActor"/>
/// call rather than newing a per-instance factory that is never disposed.
/// When the host injects its configured factory the Instance Actor logs are
/// routed through the application's logging providers.
@@ -52,7 +52,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// Notify-and-fetch (Task 10): fetches a deployment's flattened config from central
/// Notify-and-fetch: fetches a deployment's flattened config from central
/// over HTTP when a <see cref="RefreshDeploymentCommand"/> arrives. Optional — null on
/// nodes/tests that never receive a refresh; the active site path supplies it via DI.
/// </summary>
@@ -61,12 +61,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Tracks Instance Actors that are terminating as part of a redeployment, keyed by
/// the terminating actor ref. The buffered command is applied once <see cref="Terminated"/>
/// confirms the child has fully stopped (SiteRuntime-003).
/// confirms the child has fully stopped.
/// </summary>
private readonly Dictionary<IActorRef, PendingRedeploy> _pendingRedeploys = new();
/// <summary>
/// SiteRuntime-020: name → terminating actor ref shadow of <see cref="_pendingRedeploys"/>.
/// Name → terminating actor ref shadow of <see cref="_pendingRedeploys"/>.
/// Required because a third <see cref="DeployInstanceCommand"/> for the same instance
/// arriving WHILE a redeploy is still mid-termination would otherwise see
/// <c>_instanceActors.TryGetValue == false</c> and fall through to
@@ -81,7 +81,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// </summary>
private readonly Dictionary<string, IActorRef> _terminatingActorsByName = new();
/// <summary>
/// SiteRuntime-032: authoritative set of deployed instance config names (enabled
/// Authoritative set of deployed instance config names (enabled
/// AND disabled). The deployed/disabled health counts are derived from this set's
/// size, so add-on-deploy / remove-on-delete keeps the count correct for every
/// path — including deleting a DISABLED instance, which has a config row but is
@@ -105,7 +105,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <param name="serviceProvider">Optional service provider for resolving per-instance services.</param>
/// <param name="loggerFactory">Optional logger factory for creating Instance Actor loggers.</param>
/// <param name="configFetcher">
/// Optional notify-and-fetch config fetcher (Task 10). Required for the
/// Optional notify-and-fetch config fetcher. Required for the
/// <see cref="RefreshDeploymentCommand"/> path; null on nodes/tests that never
/// receive a refresh.
/// </param>
@@ -134,7 +134,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_serviceProvider = serviceProvider;
_configFetcher = configFetcher;
_logger = logger;
// SiteRuntime-015: reuse a single logger factory for all Instance Actors.
// Reuse a single logger factory for all Instance Actors.
// Prefer an explicitly injected factory, fall back to one resolved from
// the service provider, and only as a last resort use NullLoggerFactory —
// never a per-instance `new LoggerFactory()` that leaks undisposed.
@@ -148,7 +148,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<EnableInstanceCommand>(HandleEnable);
Receive<DeleteInstanceCommand>(HandleDelete);
// Notify-and-fetch (Task 10): central sends a small RefreshDeploymentCommand;
// Notify-and-fetch: central sends a small RefreshDeploymentCommand;
// the active singleton fetches the flattened config over HTTP, then reuses the
// existing apply path. The two internal results carry the fetched config (or the
// fetch error) back onto the actor thread along with the captured original sender.
@@ -156,16 +156,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<RefreshFetched>(HandleRefreshFetched);
Receive<RefreshFetchFailed>(HandleRefreshFetchFailed);
// DeploymentManager-006: query-the-site-before-redeploy idempotency.
// Query-the-site-before-redeploy idempotency.
// Central asks for the instance's currently-applied deployment identity
// before re-sending a deployment whose prior record is stuck InProgress
// or Failed due to a timeout.
Receive<DeploymentStateQueryRequest>(HandleDeploymentStateQuery);
// WP-33: Handle system-wide artifact deployment
// Handle system-wide artifact deployment
Receive<DeployArtifactsCommand>(HandleDeployArtifacts);
// SiteRuntime-021: artifact-deploy DCL push, dispatched back from the
// Artifact-deploy DCL push, dispatched back from the
// off-thread persistence task so the hash-cache mutation stays
// actor-thread-confined.
Receive<ApplyArtifactDataConnectionsToDcl>(HandleApplyArtifactDataConnectionsToDcl);
@@ -197,8 +197,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<ReadTagValuesCommand>(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
// OPC UA tag-picker address-space search (T15), secured-write execute (T14),
// and endpoint Verify (T17) — same singleton-only re-forward as the browse
// OPC UA tag-picker address-space search, secured-write execute,
// and endpoint Verify — same singleton-only re-forward as the browse
// handler above. SiteCommunicationActor routes these to this singleton
// (active node) so the local dcl-manager is the one holding the live
// DataConnectionActor children (Search/Write route there by connection name;
@@ -211,7 +211,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<VerifyEndpointCommand>(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
// T17 / D6 — OPC UA server-certificate trust. Trust is site-local: the
// OPC UA server-certificate trust. Trust is site-local: the
// trusted-peer PKI store is per-node, so a trust/remove MUST reach BOTH
// site nodes (node-a + node-b) or they diverge across failover. This
// singleton fans the corresponding per-node message out to the
@@ -232,7 +232,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<DeployPersistenceResult>(HandleDeployPersistenceResult);
// Terminated signal — drains a buffered redeployment once the previous
// Instance Actor has fully stopped (SiteRuntime-003).
// Instance Actor has fully stopped.
Receive<Terminated>(HandleTerminated);
}
@@ -281,7 +281,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Processes the loaded configs from SQLite.
///
/// SiteRuntime-008: shared scripts must be compiled before Instance Actors are
/// Shared scripts must be compiled before Instance Actors are
/// created, but the SQLite read and Roslyn compilation must not block the
/// singleton's mailbox. The compilation is run on a background task and a
/// <see cref="SharedScriptsLoaded"/> message is piped back; only then does
@@ -310,7 +310,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// SiteRuntime-008: once shared scripts have been compiled off-thread, begins
/// Once shared scripts have been compiled off-thread, begins
/// staggered Instance Actor creation for the enabled configs captured at startup.
/// </summary>
private void HandleSharedScriptsLoaded(SharedScriptsLoaded msg)
@@ -407,7 +407,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
return;
}
// SiteRuntime-020: a deploy arriving while the previous redeploy is still
// A deploy arriving while the previous redeploy is still
// terminating (the Terminated signal hasn't fired yet) used to fall through
// to ApplyDeployment(fresh), where Context.ActorOf would throw
// InvalidActorNameException because the child name is still registered.
@@ -443,7 +443,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (!_pendingRedeploys.Remove(terminated.ActorRef, out var pending))
return;
// SiteRuntime-020: drop the name-keyed shadow now that the predecessor has
// Drop the name-keyed shadow now that the predecessor has
// fully terminated and its actor name is free. Any deploy arriving after
// this point sees neither _instanceActors[name] (cleared when we stopped
// the predecessor) nor _terminatingActorsByName[name] (cleared here), so
@@ -454,7 +454,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Notify-and-fetch (Task 10): handles a small central→site
/// Notify-and-fetch: handles a small central→site
/// <see cref="RefreshDeploymentCommand"/>. Fetches the deployment's flattened config
/// from central over HTTP via <see cref="IDeploymentConfigFetcher"/>, then pipes the
/// result back to self so the existing apply path runs on the actor thread with the
@@ -491,7 +491,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Notify-and-fetch (Task 10): the config fetch succeeded — reconstruct the in-process
/// Notify-and-fetch: the config fetch succeeded — reconstruct the in-process
/// <see cref="DeployInstanceCommand"/> apply DTO and reuse the existing apply path,
/// threading the original central sender through so the
/// <see cref="DeploymentStatusResponse"/> reaches it.
@@ -509,7 +509,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Notify-and-fetch (Task 10): the config fetch failed — report
/// Notify-and-fetch: the config fetch failed — report
/// <see cref="DeploymentStatus.Failed"/> to the original central sender so the deploy
/// completes (rather than the central Ask hanging to timeout). Nothing is applied.
/// </summary>
@@ -528,7 +528,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// A redeployment is an update of an existing instance, so the deployed-instance
/// counter is only incremented for genuinely new deployments.
///
/// SiteRuntime-005: the deployer is <b>not</b> told <see cref="DeploymentStatus.Success"/>
/// The deployer is <b>not</b> told <see cref="DeploymentStatus.Success"/>
/// until SQLite persistence has committed. The site's deployed-config store is the
/// durable source of truth — a config that was never persisted would be silently lost
/// on the next restart/failover, so reporting Success before the row is committed is
@@ -569,7 +569,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// config over HTTP itself, so a large config never crosses the intra-site Akka
// hop (which would silently drop on the 128 KB frame trap). When the coords are
// absent (deploy paths other than RefreshDeployment), the standby fetch is a
// no-op miss and T18 reconciliation is the durable backstop.
// no-op miss and reconciliation is the durable backstop.
_replicationActor?.Tell(new ReplicateConfigDeploy(
instanceName, command.DeploymentId, command.RevisionHash, true,
command.CentralFetchBaseUrl ?? "", command.FetchToken ?? ""));
@@ -587,7 +587,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// SiteRuntime-005: reports the deployment outcome to central only after the
/// Reports the deployment outcome to central only after the
/// persistence result is known. On a persistence failure the Instance Actor that was
/// created optimistically is stopped and the deployed-instance counter rolled back,
/// so the in-memory state stays consistent with durable storage, and central is told
@@ -597,7 +597,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
if (result.Success)
{
// M1.6: operational `deployment` event — deploy succeeded.
// Operational `deployment` event — deploy succeeded.
LogDeploymentEvent("Info", result.InstanceName,
$"Instance {result.InstanceName} deployed (deploymentId={result.DeploymentId})");
@@ -614,7 +614,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
"Failed to persist deployment {DeploymentId} for {Instance}: {Error}",
result.DeploymentId, result.InstanceName, result.Error);
// M1.6: operational `deployment` event — deploy failed.
// Operational `deployment` event — deploy failed.
LogDeploymentEvent("Error", result.InstanceName,
$"Instance {result.InstanceName} deploy failed (deploymentId={result.DeploymentId})",
result.Error);
@@ -642,7 +642,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = command.InstanceUniqueName;
// SiteRuntime-029: a disable arriving mid-redeploy must cancel the buffered
// A disable arriving mid-redeploy must cancel the buffered
// redeploy. Otherwise HandleTerminated re-creates the Instance Actor and
// re-stores its config with isEnabled: true when the predecessor terminates,
// silently reverting the operator's disable back to enabled. Mirror the
@@ -677,7 +677,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (t.IsCompletedSuccessfully)
{
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, false));
// M1.6: operational `deployment` event — disable succeeded.
// Operational `deployment` event — disable succeeded.
LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} disabled");
}
else
@@ -732,7 +732,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (result.Error != null || result.Config == null)
{
var error = result.Error ?? $"No deployed config found for {instanceName}";
// M1.6: operational `deployment` event — enable failed.
// Operational `deployment` event — enable failed.
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} enable failed", error);
result.OriginalSender.Tell(new InstanceLifecycleResponse(
@@ -746,7 +746,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
UpdateInstanceCounts();
// M1.6: operational `deployment` event — enable succeeded.
// Operational `deployment` event — enable succeeded.
LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} enabled");
result.OriginalSender.Tell(new InstanceLifecycleResponse(
@@ -763,7 +763,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = command.InstanceUniqueName;
// SiteRuntime-029: a delete arriving while a redeploy is still terminating must
// A delete arriving while a redeploy is still terminating must
// be authoritative over the mid-redeploy bookkeeping. HandleDeploy already
// removed the instance from _instanceActors and buffered a PendingRedeploy
// keyed by the terminating ref. If we fall straight through to the
@@ -794,12 +794,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_instanceActors.Remove(instanceName);
}
// SiteRuntime-032: the deployed count is derived from the authoritative set of
// The deployed count is derived from the authoritative set of
// deployed config names, so removing the name here decrements it. Correct for a
// live, mid-redeploy, OR DISABLED instance (a disabled instance has a config row
// but is absent from both in-memory maps); a delete for a never-deployed instance
// removes nothing and leaves the count unchanged. Supersedes SiteRuntime-029's
// map-presence gate, which leaked the count on disabled-instance deletes.
// removes nothing and leaves the count unchanged. Supersedes the earlier
// map-presence check, which leaked the count on disabled-instance deletes.
_deployedInstanceNames.Remove(instanceName);
UpdateInstanceCounts();
@@ -809,7 +809,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (t.IsCompletedSuccessfully)
{
_replicationActor?.Tell(new ReplicateConfigRemove(instanceName));
// M1.6: operational `deployment` event — delete succeeded.
// Operational `deployment` event — delete succeeded.
LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} deleted");
}
else
@@ -831,7 +831,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// M1.6: fire-and-forget a <c>deployment</c> operational event to the optional
/// Fire-and-forget a <c>deployment</c> operational event to the optional
/// <see cref="ISiteEventLogger"/> on a deploy/enable/disable/delete outcome.
/// Resolved optionally and never awaited so a logging failure cannot affect the
/// deployment pipeline (matching the established ScriptActor/ScriptExecutionActor
@@ -855,7 +855,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// DeploymentManager-006: answers a central query for the instance's
/// Answers a central query for the instance's
/// currently-applied deployment identity. The site's deployed-config store
/// (SQLite) is the authoritative record — it covers both enabled and
/// disabled instances, and survives node restart/failover. If the instance
@@ -892,13 +892,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}).PipeTo(sender);
}
// ── T17 / D6 — OPC UA server-certificate trust ──
// ── OPC UA server-certificate trust ──
/// <summary>
/// The base cluster role every site node carries (in addition to its
/// per-site role <c>site-{SiteId}</c>). Used to enumerate the site nodes a
/// trust/remove must reach so node-a and node-b PKI stores stay in lock-step
/// across failover (D6). Matches the role string set in <c>NodeOptions.Role</c>
/// across failover. Matches the role string set in <c>NodeOptions.Role</c>
/// for site hosts (see <c>AkkaHostedService.BuildRoles</c>).
/// </summary>
private const string SiteClusterRole = "Site";
@@ -911,7 +911,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private static readonly TimeSpan CertBroadcastTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// T17 / D6: broadcasts a trust to the per-node <see cref="CertStoreActor"/>
/// Broadcasts a trust to the per-node <see cref="CertStoreActor"/>
/// on every Up site node so both PKI stores receive the certificate.
/// </summary>
private void HandleTrustServerCert(TrustServerCertCommand command) =>
@@ -920,7 +920,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
$"trust cert {command.Thumbprint} (connection {command.ConnectionName})");
/// <summary>
/// T17 / D6: broadcasts a remove to the per-node <see cref="CertStoreActor"/>
/// Broadcasts a remove to the per-node <see cref="CertStoreActor"/>
/// on every Up site node so the certificate leaves both PKI stores.
/// </summary>
private void HandleRemoveServerCert(RemoveServerCertCommand command) =>
@@ -929,7 +929,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
$"remove cert {command.Thumbprint}");
/// <summary>
/// T17: lists this node's trusted + rejected PKI stores by asking the LOCAL
/// Lists this node's trusted + rejected PKI stores by asking the LOCAL
/// <see cref="CertStoreActor"/> (the singleton's own node). The list reflects
/// the active node's view; a trust broadcast keeps the standby in sync.
/// </summary>
@@ -1010,8 +1010,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Tracks the configuration last sent to the DCL for each connection name, keyed by
/// a hash of the connection's protocol/endpoints/credentials/failover count
/// (SiteRuntime-010). A name whose hash is unchanged is skipped; a name whose config
/// a hash of the connection's protocol/endpoints/credentials/failover count.
/// A name whose hash is unchanged is skipped; a name whose config
/// changed re-issues a <c>CreateConnectionCommand</c> so the DCL adopts the new
/// configuration instead of keeping a stale connection after a redeployment.
/// </summary>
@@ -1021,7 +1021,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// Sets up DCL connections from the flattened config. Idempotent on unchanged
/// configuration, but re-issues the create command when a connection's endpoint,
/// credentials, backup endpoint, or failover retry count has changed since it was
/// last sent (SiteRuntime-010).
/// last sent.
/// </summary>
private void EnsureDclConnections(string configJson)
{
@@ -1049,7 +1049,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// SiteRuntime-021: hash-guarded DCL connection push shared by the inline
/// Hash-guarded DCL connection push shared by the inline
/// per-instance path (<see cref="EnsureDclConnections(string)"/>) and the
/// system-wide artifact-deploy path (<see cref="HandleDeployArtifacts"/>).
/// Unchanged config is a no-op; a changed endpoint/credentials/backup/
@@ -1091,8 +1091,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Computes a stable hash over the configuration fields that affect how the DCL
/// connects, so a changed endpoint/credential/backup/failover count is detected
/// (SiteRuntime-010).
/// connects, so a changed endpoint/credential/backup/failover count is detected.
/// </summary>
private static string ComputeConnectionConfigHash(
Commons.Types.Flattening.ConnectionConfig connConfig) =>
@@ -1103,7 +1102,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
connConfig.FailoverRetryCount);
/// <summary>
/// SiteRuntime-021: field-based core so the system-wide artifact-deploy
/// Field-based core so the system-wide artifact-deploy
/// path (which carries protocol/config-json/backup-json/failover directly
/// on <see cref="Commons.Messages.Artifacts.DataConnectionArtifact"/>) can
/// share the same hash + skip-or-resend logic as the inline-config path.
@@ -1161,7 +1160,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// ── Shared Script Loading ──
/// <summary>
/// SiteRuntime-008: reads and compiles all shared scripts on a background task so the
/// Reads and compiles all shared scripts on a background task so the
/// SQLite read and Roslyn compilation never block the singleton's mailbox thread. The
/// result is piped back as a <see cref="SharedScriptsLoaded"/> message, carrying the
/// enabled configs to resume staggered Instance Actor creation on the actor thread.
@@ -1201,7 +1200,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
else
{
// M2.11: set InstanceNotFound=true so the caller can distinguish
// Set InstanceNotFound=true so the caller can distinguish
// "not deployed on this site" from a deployed-but-empty instance.
_logger.LogWarning(
"Debug view subscribe for unknown instance {Instance}", request.InstanceUniqueName);
@@ -1228,7 +1227,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
else
{
// M2.11: set InstanceNotFound=true so the caller can distinguish
// Set InstanceNotFound=true so the caller can distinguish
// "not deployed on this site" from a deployed-but-empty instance.
_logger.LogWarning(
"Debug snapshot for unknown instance {Instance}", request.InstanceUniqueName);
@@ -1246,7 +1245,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
{
// Convert to ScriptCallRequest and Ask the Instance Actor.
// Audit Log #23 (ParentExecutionId): carry the inbound request's
// (ParentExecutionId): carry the inbound request's
// ExecutionId down as ParentExecutionId so the routed script
// execution can record its spawner.
var scriptCall = new ScriptCallRequest(
@@ -1494,7 +1493,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// WP-33: Handles system-wide artifact deployment (shared scripts, external systems, etc.).
/// Handles system-wide artifact deployment (shared scripts, external systems, etc.).
/// Persists artifacts to SiteStorageService and recompiles shared scripts.
/// </summary>
private void HandleDeployArtifacts(DeployArtifactsCommand command)
@@ -1513,7 +1512,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
try
{
// WP-33: Store shared scripts and recompile
// Store shared scripts and recompile
if (command.SharedScripts != null)
{
foreach (var script in command.SharedScripts)
@@ -1521,12 +1520,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
await _storage.StoreSharedScriptAsync(script.Name, script.Code,
script.ParameterDefinitions, script.ReturnDefinition);
// WP-33: Shared scripts recompiled on update
// Shared scripts recompiled on update
_sharedScriptLibrary.CompileAndRegister(script.Name, script.Code);
}
}
// WP-33: Store external system definitions
// Store external system definitions
if (command.ExternalSystems != null)
{
foreach (var es in command.ExternalSystems)
@@ -1536,7 +1535,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
}
// WP-33: Store database connection definitions
// Store database connection definitions
if (command.DatabaseConnections != null)
{
foreach (var db in command.DatabaseConnections)
@@ -1546,7 +1545,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
}
// DeploymentManager-025 / SiteRuntime-031: notification lists and SMTP
// Notification lists and SMTP
// configuration are central-only — sites store-and-forward notifications
// to central and never deliver over SMTP. Central no longer ships these
// (the DeployArtifactsCommand fields stay for additive compatibility but
@@ -1566,7 +1565,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
dc.BackupConfigurationJson, dc.FailoverRetryCount);
}
// SiteRuntime-021: after the SQLite store, dispatch an
// After the SQLite store, dispatch an
// internal message back to the actor thread so the DCL
// push runs through EnsureDclConnection — keeping the
// _createdConnections hash cache mutation actor-thread-
@@ -1581,7 +1580,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
self.Tell(new ApplyArtifactDataConnectionsToDcl(command.DataConnections));
}
// DeploymentManager-025 / SiteRuntime-031: SMTP configuration is
// SMTP configuration is
// central-only and is never stored on a site (see the purge above).
// Replicate artifacts to standby node
@@ -1611,7 +1610,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
return;
}
// SiteRuntime-015: reuse the shared, host-configured logger factory
// Reuse the shared, host-configured logger factory
// instead of allocating (and leaking) a fresh LoggerFactory per instance.
var props = Props.Create(() => new InstanceActor(
instanceName,
@@ -1633,7 +1632,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// SiteRuntime-021: actor-thread handler that pushes artifact-deploy data
/// Actor-thread handler that pushes artifact-deploy data
/// connection definitions to the DCL via the shared
/// <see cref="EnsureDclConnection"/> helper. Dispatched from
/// <see cref="HandleDeployArtifacts"/>'s off-thread Task so the
@@ -1676,7 +1675,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// Internal message piped back once shared scripts have been compiled off-thread
/// (SiteRuntime-008). Carries the enabled configs so staggered Instance Actor
/// Carries the enabled configs so staggered Instance Actor
/// creation resumes on the actor thread.
/// </summary>
internal record SharedScriptsLoaded(
@@ -1696,20 +1695,20 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
internal record PendingRedeploy(DeployInstanceCommand Command, IActorRef OriginalSender);
/// <summary>
/// Notify-and-fetch (Task 10): piped back to self when the deployment's flattened
/// Notify-and-fetch: piped back to self when the deployment's flattened
/// config has been fetched from central. Carries the original central sender so the
/// reused apply path replies to it.
/// </summary>
private sealed record RefreshFetched(RefreshDeploymentCommand Cmd, string ConfigJson, IActorRef ReplyTo);
/// <summary>
/// Notify-and-fetch (Task 10): piped back to self when the config fetch failed.
/// Notify-and-fetch: piped back to self when the config fetch failed.
/// Carries the original central sender so a Failed status is reported to it.
/// </summary>
private sealed record RefreshFetchFailed(RefreshDeploymentCommand Cmd, string Error, IActorRef ReplyTo);
/// <summary>
/// SiteRuntime-021: internal message dispatched from
/// Internal message dispatched from
/// <see cref="HandleDeployArtifacts"/>'s off-thread persistence task back
/// onto the actor thread, so the DCL push (and its hash-cache mutation)
/// runs through <see cref="EnsureDclConnection"/> without crossing
@@ -25,13 +25,13 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// (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.
/// 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.
/// Creates child Script Actors and Alarm Actors on startup.
/// Tell for tag value updates, attribute notifications, stream publishing.
/// Ask for CallScript, debug snapshot.
/// WP-25: Debug view backend — snapshot + stream subscription.
/// Debug view backend — snapshot + stream subscription.
/// </summary>
public class InstanceActor : ReceiveActor
{
@@ -68,7 +68,7 @@ public class InstanceActor : ReceiveActor
private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new();
private FlattenedConfiguration? _configuration;
// MV-8: resolved attributes indexed by canonical name. The TagValueUpdate
// 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
@@ -144,11 +144,11 @@ public class InstanceActor : ReceiveActor
{
foreach (var attr in _configuration.Attributes)
{
// MV-8: index resolved attributes for O(1) lookup on the hot
// 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
// 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
@@ -176,7 +176,7 @@ public class InstanceActor : ReceiveActor
// Handle static attribute writes
Receive<SetStaticAttributeCommand>(HandleSetStaticAttribute);
// SiteRuntime-019: the disable/enable lifecycle is owned entirely by the
// 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
@@ -184,10 +184,10 @@ public class InstanceActor : ReceiveActor
// 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)
// Handle script call requests — route to appropriate Script Actor (Ask pattern)
Receive<ScriptCallRequest>(HandleScriptCallRequest);
// WP-22/23: Handle attribute value changes from DCL (Tell pattern)
// Handle attribute value changes from DCL (Tell pattern)
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// WaitForAttribute (spec §4.2): event-driven "wait for value" waiter
@@ -207,16 +207,16 @@ public class InstanceActor : ReceiveActor
Receive<SubscribeTagsResponse>(_ => { }); // Ack from DCL subscribe — no action needed
Receive<ConnectionQualityChanged>(HandleConnectionQualityChanged);
// WP-16: Handle alarm state changes from Alarm Actors (Tell pattern)
// Handle alarm state changes from Alarm Actors (Tell pattern)
Receive<AlarmStateChanged>(HandleAlarmStateChanged);
// SiteRuntime-027: a NativeAlarmActor tells us when one of its native
// 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);
// WP-25: Debug view subscribe/unsubscribe (Ask pattern for snapshot)
// Debug view subscribe/unsubscribe (Ask pattern for snapshot)
Receive<SubscribeDebugViewRequest>(HandleSubscribeDebugView);
Receive<UnsubscribeDebugViewRequest>(HandleUnsubscribeDebugView);
@@ -233,7 +233,7 @@ public class InstanceActor : ReceiveActor
base.PreStart();
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
// M1.6: operational `instance_lifecycle` event — instance started.
// 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");
@@ -257,7 +257,7 @@ public class InstanceActor : ReceiveActor
/// <inheritdoc />
protected override void PostStop()
{
// M1.6: operational `instance_lifecycle` event — instance stopped. An
// 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");
@@ -265,7 +265,7 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// M1.6: fire-and-forget an <c>instance_lifecycle</c> operational event to the
/// 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/ScriptExecutionActor pattern).
@@ -311,7 +311,7 @@ public class InstanceActor : ReceiveActor
/// <summary>
/// Handles an attribute write (<c>Instance.SetAttribute</c> / Inbound API).
/// WP-24: State mutation serialized through this actor's mailbox.
/// 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
@@ -330,7 +330,7 @@ public class InstanceActor : ReceiveActor
var resolved = _configuration?.Attributes
.FirstOrDefault(a => a.CanonicalName == command.AttributeName);
// SiteRuntime-025: reject writes targeting an attribute that does not exist
// 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
@@ -373,7 +373,7 @@ public class InstanceActor : ReceiveActor
/// </summary>
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
{
// MV-7: command.Value is the canonical form — a plain string for scalars,
// 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
@@ -382,7 +382,7 @@ public class InstanceActor : ReceiveActor
if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved)
&& IsListAttribute(resolved))
{
// MV-7: the script path pre-encodes valid canonical JSON via ScopeAccessors,
// 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
@@ -415,7 +415,7 @@ public class InstanceActor : ReceiveActor
_attributes[command.AttributeName] = command.Value;
}
// Publish attribute change to stream (WP-23) and notify children
// Publish attribute change to stream and notify children
var changed = new AttributeValueChanged(
_instanceUniqueName,
command.AttributeName,
@@ -469,7 +469,7 @@ public class InstanceActor : ReceiveActor
return;
}
// MV (C1): for a data-sourced List attribute the incoming command.Value is
// 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
@@ -632,8 +632,8 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// WP-15: Routes script call requests to the appropriate Script Actor.
/// Uses Ask pattern (WP-22).
/// Routes script call requests to the appropriate Script Actor.
/// Uses Ask pattern.
/// </summary>
private void HandleScriptCallRequest(ScriptCallRequest request)
{
@@ -641,7 +641,7 @@ public class InstanceActor : ReceiveActor
{
// 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
// ParentExecutionId set by an inbound-API-routed
// call is carried through to the Script Actor verbatim.
scriptActor.Forward(request);
}
@@ -656,12 +656,12 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// WP-22/23: Handles attribute value changes from DCL or static writes.
/// Handles attribute value changes from DCL or static writes.
/// Updates in-memory state, publishes to stream, and notifies children.
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// WP-24: State mutation serialized through this actor
// State mutation serialized through this actor
_attributes[changed.AttributeName] = changed.Value;
_attributeQualities[changed.AttributeName] = changed.Quality;
_attributeTimestamps[changed.AttributeName] = changed.Timestamp;
@@ -791,10 +791,10 @@ public class InstanceActor : ReceiveActor
// we resolve and convert per attribute rather than once for the tag.
foreach (var attrName in attrNames)
{
// MV-8: O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
// O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
_resolvedAttributeByName.TryGetValue(attrName, out var resolved);
// MV-8: a List-typed attribute coerces the incoming OPC UA array
// 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.
@@ -845,12 +845,12 @@ public class InstanceActor : ReceiveActor
&& dt == DataType.List;
/// <summary>
/// MV-7: decodes a STATIC (authored / overridden) attribute's canonical value
/// 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 MV-8's <see cref="TryCoerceListValue"/> (which
/// 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
@@ -879,7 +879,7 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
/// Coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
/// into a typed <c>List&lt;elementClrType&gt;</c> matching the attribute's
/// <see cref="ResolvedAttribute.ElementDataType"/>. Each element is converted
/// with invariant culture (round-trip parse for DateTime). Returns
@@ -1008,7 +1008,7 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// WP-16: Handles alarm state changes from Alarm Actors.
/// Handles alarm state changes from Alarm Actors.
/// Updates in-memory alarm state and publishes to stream.
/// </summary>
private void HandleAlarmStateChanged(AlarmStateChanged changed)
@@ -1019,12 +1019,12 @@ public class InstanceActor : ReceiveActor
// DebugView snapshot reflects it — native alarms have no _alarmActors entry.
_latestAlarmEvents[changed.AlarmName] = changed;
// WP-23: Publish to site-wide stream
// Publish to site-wide stream
_streamManager?.PublishAlarmStateChanged(changed);
}
/// <summary>
/// SiteRuntime-027: evicts a native condition's key from the alarm-state maps once
/// 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
@@ -1043,7 +1043,7 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// WP-25: Debug view subscribe — returns snapshot and begins streaming.
/// Debug view subscribe — returns snapshot and begins streaming.
/// </summary>
private void HandleSubscribeDebugView(SubscribeDebugViewRequest request)
{
@@ -1123,7 +1123,7 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// WP-25: Debug view unsubscribe (SiteRuntime-013).
/// 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"/>
@@ -1164,7 +1164,7 @@ public class InstanceActor : ReceiveActor
/// <summary>
/// Publishes attribute change to stream and notifies child Script/Alarm actors.
/// WP-22: Tell for attribute notifications (fire-and-forget, never blocks).
/// Tell for attribute notifications (fire-and-forget, never blocks).
/// </summary>
/// <param name="changed">The attribute change to publish.</param>
/// <param name="evaluateWaiters">
@@ -1178,7 +1178,7 @@ public class InstanceActor : ReceiveActor
/// </param>
private void PublishAndNotifyChildren(AttributeValueChanged changed, bool evaluateWaiters = true)
{
// WP-23: Publish to site-wide stream
// Publish to site-wide stream
_streamManager?.PublishAttributeValueChanged(changed);
// Notify Script Actors (for value-change and conditional triggers)
@@ -1286,14 +1286,14 @@ public class InstanceActor : ReceiveActor
foreach (var kvp in result.Overrides)
{
// MV-7: persisted override values are canonical strings — a JSON array
// 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)
&& IsListAttribute(resolved))
{
// NJ-4: decode the stored List override (both old array-of-strings
// 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
@@ -1334,11 +1334,11 @@ public class InstanceActor : ReceiveActor
/// <summary>
/// Creates child Script Actors and Alarm Actors from the flattened configuration.
/// WP-15: Script Actors spawned per script definition.
/// WP-16: Alarm Actors spawned per alarm definition, as peers to Script Actors.
/// WP-32: Compilation errors reject entire instance deployment (logged but actor still starts).
/// Script Actors spawned per script definition.
/// Alarm Actors spawned per alarm definition, as peers to Script Actors.
/// Compilation errors reject entire instance deployment (logged but actor still starts).
///
/// SiteRuntime-017: each child is seeded from a private point-in-time snapshot
/// 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
@@ -1349,7 +1349,7 @@ public class InstanceActor : ReceiveActor
{
if (_configuration == null) return;
// SiteRuntime-017: snapshot the live attribute dictionary once, on the
// 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
@@ -1395,7 +1395,7 @@ public class InstanceActor : ReceiveActor
foreach (var alarm in _configuration.Alarms)
{
Script<object?>? onTriggerScript = null;
// M2.5 (#9): the on-trigger script's per-script execution timeout,
// The on-trigger script's per-script execution timeout,
// captured from its ResolvedScript so the AlarmExecutionActor can
// apply perScript ?? global. Null when there is no on-trigger script.
int? onTriggerTimeoutSeconds = null;
@@ -1441,7 +1441,7 @@ public class InstanceActor : ReceiveActor
attributeSnapshot,
_healthCollector,
_serviceProvider,
// M2.5 (#9): per-script timeout for the alarm on-trigger script.
// Per-script timeout for the alarm on-trigger script.
onTriggerTimeoutSeconds));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// Task 15: Native Alarm Actor — child of Instance Actor, peer to the computed
/// Native Alarm Actor — child of Instance Actor, peer to the computed
/// <see cref="AlarmActor"/>. Mirrors a single source binding's native alarms
/// read-only: subscribes through the DCL, applies snapshot/live transitions,
/// retains active+unacked conditions, persists to site SQLite, and emits an
@@ -41,7 +41,7 @@ public class NativeAlarmActor : ReceiveActor
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// M1.5: severity at or above which a native-alarm raise is logged as
/// Severity at or above which a native-alarm raise is logged as
/// <c>Error</c> to the site event log; below it, raises log as <c>Warning</c>.
/// Mirrors the 01000 condition-severity scale.
/// </summary>
@@ -66,7 +66,7 @@ public class NativeAlarmActor : ReceiveActor
/// <param name="nativeKind">Alarm kind to stamp on emitted events (OPC UA vs MxAccess); set by the
/// Instance Actor from the connection protocol. Defaults to <see cref="AlarmKind.NativeOpcUa"/>.</param>
/// <param name="serviceProvider">Optional DI service provider used to resolve the optional
/// <see cref="ISiteEventLogger"/> for M1.5 <c>alarm</c> operational events. Fire-and-forget;
/// <see cref="ISiteEventLogger"/> for <c>alarm</c> operational events. Fire-and-forget;
/// a logging failure never affects the mirror.</param>
public NativeAlarmActor(
ResolvedNativeAlarmSource source,
@@ -166,7 +166,7 @@ public class NativeAlarmActor : ReceiveActor
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
null, row.LastTransitionAt, string.Empty, string.Empty);
_alarms[row.SourceReference] = t;
// M1.5: rehydration replays last-known state on (re)start — surface it
// Rehydration replays last-known state on (re)start — surface it
// upward for the DebugView but do NOT re-log it as a fresh operational
// event (it is not a live transition).
Emit(t, t.Condition, logSiteEvent: false);
@@ -205,7 +205,7 @@ public class NativeAlarmActor : ReceiveActor
{
Emit(prior, prior.Condition with { Active = false });
PersistDelete(sourceRef);
// SiteRuntime-027: this condition is gone for good — tell the parent
// This condition is gone for good — tell the parent
// to evict its _latestAlarmEvents key so it does not retain a stale
// (Normal) entry forever.
NotifyParentDropped(sourceRef);
@@ -217,7 +217,7 @@ public class NativeAlarmActor : ReceiveActor
{
_alarms[sourceRef] = t;
PersistUpsert(t);
// M1.5: a snapshot replay is a re-sync of the source's current
// A snapshot replay is a re-sync of the source's current
// active set on (re)subscribe, NOT a live transition — surface it
// upward for the DebugView but do NOT re-log an `alarm` operational
// event. Otherwise every DCL reconnect would re-emit an `alarm`
@@ -249,7 +249,7 @@ public class NativeAlarmActor : ReceiveActor
{
_alarms.Remove(t.SourceReference);
PersistDelete(t.SourceReference);
// SiteRuntime-027: evict the parent's _latestAlarmEvents key for the
// Evict the parent's _latestAlarmEvents key for the
// now-resolved condition so it does not leak.
NotifyParentDropped(t.SourceReference);
}
@@ -303,7 +303,7 @@ public class NativeAlarmActor : ReceiveActor
{
var sourceRef = evicted.SourceReference;
// SiteRuntime-028: the sibling drop paths (ApplySnapshotSwap, the
// The sibling drop paths (ApplySnapshotSwap, the
// ApplyLiveTransition retention drop) always emit a return-to-normal
// before the condition leaves the mirror. EnforceCap previously dropped
// a condition whose last-emitted state could still be Active, with no
@@ -319,7 +319,7 @@ public class NativeAlarmActor : ReceiveActor
_alarms.Remove(sourceRef);
PersistDelete(sourceRef);
// SiteRuntime-027: this condition is gone for good — evict the parent's
// This condition is gone for good — evict the parent's
// _latestAlarmEvents key so it does not retain a stale entry.
NotifyParentDropped(sourceRef);
_logger.LogWarning(
@@ -329,7 +329,7 @@ public class NativeAlarmActor : ReceiveActor
}
/// <summary>
/// SiteRuntime-027: signals the parent Instance Actor that a native condition has
/// Signals the parent Instance Actor that a native condition has
/// left the mirror for good so it can evict the matching <c>_latestAlarmEvents</c>
/// key. Always sent AFTER the condition's final return-to-normal
/// <see cref="AlarmStateChanged"/> emit, so the stream still sees the clear.
@@ -345,7 +345,7 @@ public class NativeAlarmActor : ReceiveActor
/// <param name="t">The mirrored transition.</param>
/// <param name="condition">The condition state to surface (may differ from <paramref name="t"/>'s
/// own condition, e.g. a synthesised return-to-normal on snapshot swap).</param>
/// <param name="logSiteEvent">M1.5: when <c>true</c> (live + snapshot transitions), emit an
/// <param name="logSiteEvent">When <c>true</c> (live + snapshot transitions), emit an
/// <c>alarm</c> operational event. Suppressed for SQLite rehydration so a node restart does not
/// re-log every last-known condition.</param>
private void Emit(NativeAlarmTransition t, AlarmConditionState condition, bool logSiteEvent = true)
@@ -380,7 +380,7 @@ public class NativeAlarmActor : ReceiveActor
}
/// <summary>
/// M1.5: fire-and-forget an <c>alarm</c> operational event mirroring a native
/// Fire-and-forget an <c>alarm</c> operational event mirroring a native
/// condition transition. An active condition is a raise (severity by the
/// condition's severity); an inactive condition is a return-to-normal; an
/// acknowledge transition is informational. Resolved optionally and never
@@ -14,7 +14,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-15: Script Actor — coordinator actor, child of Instance Actor.
/// Script Actor — coordinator actor, child of Instance Actor.
/// Holds compiled script delegate, manages trigger configuration, and spawns
/// ScriptExecutionActor children per invocation. Does not block on child completion.
///
@@ -45,7 +45,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
private TimeSpan? _minTimeBetweenRuns;
/// <summary>
/// M2.5 (#9): the per-script execution timeout in seconds, or null to use the
/// The per-script execution timeout in seconds, or null to use the
/// global default. Threaded down to each spawned <see cref="ScriptExecutionActor"/>,
/// which applies <c>perScript ?? global</c> (and treats ≤ 0 as "use global").
/// </summary>
@@ -69,7 +69,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
private const string WhileTrueTimerKey = "whiletrue-trigger";
/// <summary>
/// SiteRuntime-017: the exact dictionary instance this actor was seeded from
/// The exact dictionary instance this actor was seeded from
/// at construction. The Instance Actor must pass a private snapshot here, not
/// its live <c>_attributes</c> field — sharing the live dictionary lets this
/// constructor enumerate it while the Instance Actor mutates it on another
@@ -207,9 +207,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
return;
}
// Audit Log #23 (ParentExecutionId): carry any inbound-routed
// ParentExecutionId through to the ScriptExecutionActor so the routed
// script's ScriptRuntimeContext can record its spawner. Null for normal
// (ParentExecutionId): carry any inbound-routed ParentExecutionId
// through to the ScriptExecutionActor so the routed script's
// ScriptRuntimeContext can record its spawner. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
SpawnExecution(
request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
@@ -413,7 +413,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
{
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
// SiteRuntime-009: the actor's mailbox stays on the default dispatcher, but the
// The actor's mailbox stays on the default dispatcher, but the
// script body itself runs on the dedicated ScriptExecutionScheduler (a bounded
// set of dedicated threads), so blocking script I/O is contained there and
// cannot starve the shared .NET thread pool.
@@ -432,10 +432,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
_scope,
_healthCollector,
_serviceProvider,
// Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
// (ParentExecutionId): null for trigger-driven runs;
// an inbound-API-routed call supplies the inbound request's id.
parentExecutionId,
// M2.5 (#9): per-script timeout override (null = use global).
// Per-script timeout override (null = use global).
_executionTimeoutSeconds));
Context.ActorOf(props, executionId);
@@ -455,7 +455,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
try
{
// Use InvariantCulture so a string attribute value like "1.5" parses
// consistently regardless of the host locale (SiteRuntime-023). For
// consistently regardless of the host locale. For
// purely-numeric inputs the culture argument is a no-op, but it is
// safe and future-proof for string-typed attribute values arriving
// from scripts or the data connection layer.
@@ -14,17 +14,17 @@ using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// <summary>
/// WP-15: Script Execution Actor -- short-lived child of Script Actor.
/// Script Execution Actor -- short-lived child of Script Actor.
/// Receives compiled code, params, Instance Actor ref, and call depth.
/// Executes the script via Script Runtime API, returns result, then stops.
///
/// The actor itself and its mailbox run on the default Akka dispatcher; only the
/// script body is dispatched off the actor thread, onto the dedicated
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptExecutionScheduler"/>
/// (SiteRuntime-009), so blocking script I/O cannot starve the shared thread pool
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptExecutionScheduler"/>,
/// so blocking script I/O cannot starve the shared thread pool
/// or stall other Akka dispatchers.
///
/// WP-32: Script failures are logged but do not disable the script.
/// Script failures are logged but do not disable the script.
/// Supervision: Stop on unhandled exception (parent ScriptActor decides).
/// </summary>
public class ScriptExecutionActor : ReceiveActor
@@ -47,7 +47,7 @@ public class ScriptExecutionActor : ReceiveActor
/// <param name="healthCollector">Optional health collector for recording execution metrics.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
/// <param name="parentExecutionId">ExecutionId of the spawning inbound-API execution for audit correlation; null for normal runs.</param>
/// <param name="executionTimeoutSeconds">M2.5 (#9): per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
/// <param name="executionTimeoutSeconds">Per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
public ScriptExecutionActor(
string scriptName,
string instanceName,
@@ -63,11 +63,11 @@ public class ScriptExecutionActor : ReceiveActor
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// The spawning execution's
// ExecutionId for an inbound-API-routed call. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
Guid? parentExecutionId = null,
// M2.5 (#9): per-script execution timeout override (seconds). Null or
// Per-script execution timeout override (seconds). Null or
// non-positive falls back to the global ScriptExecutionTimeoutSeconds.
int? executionTimeoutSeconds = null)
{
@@ -102,14 +102,14 @@ public class ScriptExecutionActor : ReceiveActor
Guid? parentExecutionId,
int? executionTimeoutSeconds)
{
// M2.5 (#9): per-script timeout overrides the global default. A null or
// Per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
var timeout = TimeSpan.FromSeconds(
executionTimeoutSeconds is { } perScript && perScript > 0
? perScript
: options.ScriptExecutionTimeoutSeconds);
// SiteRuntime-009: run the script body on the dedicated script-execution
// Run the script body on the dedicated script-execution
// scheduler, not the shared .NET thread pool, so blocking script I/O cannot
// starve the global pool and stall Akka dispatchers / HTTP handling.
var scheduler = ScriptExecutionScheduler.Shared(options);
@@ -136,23 +136,23 @@ public class ScriptExecutionActor : ReceiveActor
// provider supplies the site id stamped on enqueued notifications.
StoreAndForwardService? storeAndForward = null;
var siteId = string.Empty;
// Audit Log #23 (M2 Bundle F): the writer is a singleton (FallbackAuditWriter
// The writer is a singleton (FallbackAuditWriter
// composes the SQLite hot-path + drop-oldest ring); null in tests / hosts
// that haven't called AddAuditLog, which the helper handles as a no-op.
IAuditWriter? auditWriter = null;
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
// Site-local tracking store
// backing Tracking.Status(id). Singleton; null in tests / hosts
// that haven't wired the store, which the helper handles by
// throwing on access.
IOperationTrackingStore? operationTrackingStore = null;
// Audit Log #23 (M3 Bundle F — Task F1): site-side cached-call
// Site-side cached-call
// telemetry forwarder. Singleton bound to the AuditLog
// composition root; null in tests / hosts that haven't called
// AddAuditLog, in which case the cached-call helpers degrade
// to the no-emission path (the underlying S&F handoff still
// happens and a TrackedOperationId is still returned).
ICachedCallTelemetryForwarder? cachedForwarder = null;
// SourceNode-stamping (Tasks 13/14): the local node name
// SourceNode-stamping: the local node name
// resolved from INodeIdentityProvider — node-a/node-b on site
// hosts. Null in tests / hosts that haven't registered the
// provider, in which case NotificationSubmit.SourceNode and
@@ -191,34 +191,34 @@ public class ScriptExecutionActor : ReceiveActor
// Notification Outbox (FU3): stamp the executing script onto outbound
// notifications using the Site Event Logging "Source" convention.
sourceScript: $"ScriptActor:{scriptName}",
// Audit Log #23 (M2 Bundle F): emit one ApiOutbound/ApiCall row per
// Emit one ApiOutbound/ApiCall row per
// ExternalSystem.Call. Writer is best-effort; failures are logged
// and swallowed inside the helper so the script's call path is
// never aborted by an audit failure.
auditWriter: auditWriter,
// Audit Log #23 (M3 Bundle A — Task A3): site-local tracking store
// Site-local tracking store
// backing Tracking.Status(id). Authoritative source of truth for
// cached-call status — read directly by the script API.
operationTrackingStore: operationTrackingStore,
// Audit Log #23 (M3 Bundle F — Task F1): cached-call telemetry
// Cached-call telemetry
// forwarder for ExternalSystem.CachedCall / Database.CachedWrite
// CachedSubmit emission + the immediate-success terminal-row
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
cachedForwarder: cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// The spawning execution's
// id for an inbound-API-routed call. The routed script still
// mints its own fresh ExecutionId — this records the spawner.
// Null for normal (tag-change / timer) runs.
parentExecutionId: parentExecutionId,
// SourceNode-stamping (Tasks 13/14): the local node name
// SourceNode-stamping: the local node name
// (node-a/node-b on a site) — threaded down so Notify.Send
// and the four cached-call telemetry constructors can stamp
// it onto NotificationSubmit.SourceNode and
// SiteCallOperational.SourceNode respectively.
sourceNode: sourceNode,
// M2.12 (#25): thread the singleton site event logger so
// Thread the singleton site event logger so
// recursion-limit violations at CallScript/CallShared emit a
// script Error site event in addition to ILogger.LogError.
siteEventLogger: siteEventLogger,
@@ -236,7 +236,7 @@ public class ScriptExecutionActor : ReceiveActor
Scope = scope
};
// M1.8: operational `script` event — execution started. Fire-and-forget
// Operational `script` event — execution started. Fire-and-forget
// (the `_ =` discards the task) so the event log can never block or
// fault the script's own run; mirrors the existing Error-path emit.
_ = siteEventLogger?.LogEventAsync(
@@ -251,7 +251,7 @@ public class ScriptExecutionActor : ReceiveActor
replyTo.Tell(new ScriptCallResult(correlationId, true, state.ReturnValue, null));
}
// M1.8: operational `script` event — execution completed successfully.
// Operational `script` event — execution completed successfully.
_ = siteEventLogger?.LogEventAsync(
"script", "Info", instanceName, $"ScriptActor:{scriptName}",
$"Script '{scriptName}' on instance '{instanceName}' completed");
@@ -265,7 +265,7 @@ public class ScriptExecutionActor : ReceiveActor
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
logger.LogWarning(errorMsg);
// WP-32: Failures recorded to site event log; script NOT disabled after failure.
// Failures recorded to site event log; script NOT disabled after failure.
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
@@ -279,7 +279,7 @@ public class ScriptExecutionActor : ReceiveActor
catch (Exception ex)
{
healthCollector?.IncrementScriptError();
// WP-32: Failures recorded to site event log; script NOT disabled after failure.
// Failures recorded to site event log; script NOT disabled after failure.
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance}", scriptName, instanceName);
@@ -154,9 +154,9 @@ public class SiteReplicationActor : ReceiveActor
{
if (string.IsNullOrEmpty(msg.CentralFetchBaseUrl))
{
// The direct DeployInstanceCommand cross-cluster wire path was retired (Task 14).
// The direct DeployInstanceCommand cross-cluster wire path was retired.
// This guard is a defensive fallback: skip quietly rather than calling FetchAsync("")
// and logging a spurious error. T18 reconciliation backstops any missed writes.
// and logging a spurious error. Reconciliation backstops any missed writes.
_logger.LogDebug(
"No fetch coords for {Instance} (deployment {DeploymentId}) — skipping replicated fetch; T18 reconciliation is the backstop",
msg.InstanceName, msg.DeploymentId);
@@ -178,7 +178,7 @@ public class SiteReplicationActor : ReceiveActor
// Notify-and-fetch: the peer sent only the id, so the standby fetches the config
// itself (off-thread; best-effort fire-and-forget, matching the no-ack replication
// model). The guarded write only overwrites a strictly-older local row. A single
// fetch attempt — T18 reconciliation is the durable backstop for a lost fetch.
// fetch attempt — reconciliation is the durable backstop for a lost fetch.
_configFetcher.FetchAsync(msg.CentralFetchBaseUrl, msg.DeploymentId, msg.FetchToken, CancellationToken.None)
.ContinueWith(async t =>
{
@@ -259,7 +259,7 @@ public class SiteReplicationActor : ReceiveActor
foreach (var db in command.DatabaseConnections)
await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString, db.MaxRetries, db.RetryDelay);
// DeploymentManager-025 / SiteRuntime-031: notification lists and SMTP
// Notification lists and SMTP
// configuration are central-only and are never persisted on a site.
// Mirror the primary apply path: purge any pre-fix rows (including the
// plaintext SMTP password) instead of writing the command's
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
/// <summary>
/// SiteRuntime-027: terminal-drop signal sent from a <c>NativeAlarmActor</c> to its
/// Terminal-drop signal sent from a <c>NativeAlarmActor</c> to its
/// parent <c>InstanceActor</c> when a native condition leaves the mirror for good —
/// the snapshot-swap removal, the live-transition retention drop
/// (<c>inactive &amp;&amp; acknowledged</c>), and the cap eviction. The parent removes the
@@ -27,7 +27,7 @@ public class SiteStorageService
/// <summary>
/// Creates a new (unopened) SQLite connection against the site database.
/// Exposed so site-local repositories can open their own connections without
/// reaching into private state via reflection (SiteRuntime-006). The caller owns
/// reaching into private state via reflection. The caller owns
/// the connection and is responsible for opening and disposing it.
/// </summary>
/// <returns>A new, unopened <see cref="SqliteConnection"/> against the site database.</returns>
@@ -437,7 +437,7 @@ public class SiteStorageService
_logger.LogDebug("Cleared static overrides for {Instance}", instanceName);
}
// ── Task 14: Native Alarm State store (read-only mirror of source A&C conditions) ──
// ── Native Alarm State store (read-only mirror of source A&C conditions) ──
/// <summary>
/// Inserts or updates a single mirrored native alarm condition, keyed by
@@ -550,7 +550,7 @@ public class SiteStorageService
_logger.LogDebug("Cleared native alarm state for {Instance}", instanceName);
}
// ── WP-33: Shared Script CRUD ──
// ── Shared Script CRUD ──
/// <summary>
/// Stores or updates a shared script. Uses UPSERT semantics.
@@ -613,7 +613,7 @@ public class SiteStorageService
return results;
}
// ── WP-33: External System CRUD ──
// ── External System CRUD ──
/// <summary>
/// Stores or updates an external system definition.
@@ -651,7 +651,7 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
// ── WP-33: Database Connection CRUD ──
// ── Database Connection CRUD ──
/// <summary>
/// Stores or updates a database connection definition.
@@ -686,10 +686,10 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
// ── WP-33: Notification List CRUD ──
// ── Notification List CRUD ──
/// <summary>
/// DeploymentManager-025 / SiteRuntime-031: notification delivery is central-only.
/// Notification delivery is central-only.
/// Sites store-and-forward notifications to the central cluster and never deliver
/// over SMTP, so notification lists and SMTP configuration must never live on a
/// site. This purges every row from the site-local <c>notification_lists</c> and
@@ -738,7 +738,7 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
// ── WP-33: SMTP Configuration CRUD ──
// ── SMTP Configuration CRUD ──
/// <summary>
/// Stores or updates an SMTP configuration.
@@ -887,7 +887,7 @@ public class DeployedInstance
}
/// <summary>
/// Represents a shared script stored locally in SQLite (WP-33).
/// Represents a shared script stored locally in SQLite.
/// </summary>
public class StoredSharedScript
{
@@ -937,6 +937,6 @@ public class StoredDataConnectionDefinition
}
/// <summary>
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table (Task 14).
/// A single mirrored native alarm condition row from the site-local <c>native_alarm_state</c> table.
/// </summary>
public record NativeAlarmRow(string SourceReference, string ConditionJson, DateTimeOffset LastTransitionAt);
@@ -248,7 +248,7 @@ public class SiteExternalSystemRepository : IExternalSystemRepository
/// <summary>
/// Creates a new SQLite connection against the site database via
/// <see cref="SiteStorageService.CreateConnection"/> (SiteRuntime-006). The
/// <see cref="SiteStorageService.CreateConnection"/>. The
/// connection string is owned by <see cref="SiteStorageService"/>; the repository
/// no longer reaches into its private state via reflection.
/// </summary>
@@ -296,7 +296,7 @@ public class SiteExternalSystemRepository : IExternalSystemRepository
}
/// <summary>
/// Generates a stable positive integer ID from a string name (SiteRuntime-007).
/// Generates a stable positive integer ID from a string name.
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
/// which is randomized per process on .NET Core and would therefore change every
/// time the process restarts — breaking any caller that stored an ID and later
@@ -227,7 +227,7 @@ public class SiteNotificationRepository : INotificationRepository
/// <summary>
/// Creates a new SQLite connection against the site database via
/// <see cref="SiteStorageService.CreateConnection"/> (SiteRuntime-006) instead of
/// <see cref="SiteStorageService.CreateConnection"/> instead of
/// reaching into its private connection-string field via reflection.
/// </summary>
private SqliteConnection CreateConnection() => _storage.CreateConnection();
@@ -292,7 +292,7 @@ public class SiteNotificationRepository : INotificationRepository
}
/// <summary>
/// Generates a stable positive integer ID from a string name (SiteRuntime-007).
/// Generates a stable positive integer ID from a string name.
/// Uses a deterministic FNV-1a hash rather than <see cref="string.GetHashCode()"/>,
/// which is randomized per process on .NET Core and would change every restart.
/// </summary>
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
/// <summary>
/// SiteRuntime-007: deterministic synthetic-ID generation for site-local artifacts.
/// Deterministic synthetic-ID generation for site-local artifacts.
///
/// The site SQLite tables are keyed by name rather than an auto-increment integer, but
/// the shared repository contracts (<c>IExternalSystemRepository</c>,
@@ -10,12 +10,12 @@ using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Audit Log #23 — M4 Bundle A: <see cref="DbCommand"/> decorator that emits
/// <see cref="DbCommand"/> decorator that emits
/// exactly one <c>DbOutbound</c>/<c>DbWrite</c> audit event per execution.
/// </summary>
/// <remarks>
/// <para>
/// <b>Vocabulary lock (M4 plan):</b> both writes (Execute / ExecuteScalar) and
/// <b>Vocabulary lock:</b> both writes (Execute / ExecuteScalar) and
/// reads (ExecuteReader) emit <see cref="AuditKind.DbWrite"/> on the
/// <see cref="AuditChannel.DbOutbound"/> channel. The <c>Extra</c> JSON column
/// distinguishes them — <c>{"op":"write","rowsAffected":N}</c> for writes,
@@ -41,7 +41,7 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
/// ParentExecutionId: the spawning execution's id when this
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
/// alongside <see cref="_executionId"/> and stamped onto the <c>DbWrite</c>
/// audit row.
@@ -136,9 +136,9 @@ internal sealed class AuditingDbCommand : DbCommand
// the wrapper, but writes from the user go through to the inner
// command so the underlying provider keeps its wiring intact.
get => _wrappingConnection ?? _inner.Connection;
// SiteRuntime-022: unwrap the AuditingDbConnection wrapper via its
// Unwrap the AuditingDbConnection wrapper via its
// own internal Inner accessor instead of reflecting into a private
// _inner field. Reflection was the original SiteRuntime-006 anti-
// _inner field. Reflection was the original anti-
// pattern (and is forbidden inside script bodies by the trust
// model) — both classes are internal sealed in the same assembly,
// so the proper API surface is available without leaking anything
@@ -223,7 +223,7 @@ internal sealed class AuditingDbCommand : DbCommand
}
finally
{
// ExecuteScalar is classified as "write" per the M4 vocabulary
// ExecuteScalar is classified as "write" per the vocabulary
// lock — it's a single-value execution; rowsAffected mirrors the
// inner command's value if exposed (DbCommand has no RecordsAffected
// property, so we report -1 when the provider didn't surface it).
@@ -382,7 +382,7 @@ internal sealed class AuditingDbCommand : DbCommand
/// <summary>
/// Best-effort emission of one <c>DbOutbound</c>/<c>DbWrite</c> audit row.
/// Mirrors the M2 Bundle F <c>EmitCallAudit</c> 3-layer fail-safe pattern.
/// Mirrors the <c>EmitCallAudit</c> 3-layer fail-safe pattern.
/// </summary>
private void EmitAudit(
DateTime occurredAtUtc,
@@ -463,7 +463,7 @@ internal sealed class AuditingDbCommand : DbCommand
}
// RequestSummary captures the SQL statement + parameter values by
// default per the alog.md M4 acceptance criteria. Per-target SQL
// default per the alog.md acceptance criteria. Per-target SQL
// parameter redaction is configured via AuditLogOptions.PerTargetOverrides
// (RedactSqlParamsMatching) and applied at write time by the redactor.
string? requestSummary = BuildRequestSummary();
@@ -485,13 +485,13 @@ internal sealed class AuditingDbCommand : DbCommand
// (e.g. a shared script running inline).
actor: _sourceScript,
target: target,
// Audit Log #23: a sync one-shot DB write has no operation
// A sync one-shot DB write has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so this row shares an id with the other sync
// trust-boundary rows from the same script run.
correlationId: null,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's id;
// ParentExecutionId: the spawning execution's id;
// null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -510,7 +510,7 @@ internal sealed class AuditingDbCommand : DbCommand
/// <summary>
/// Compose a JSON request summary capturing the SQL statement and
/// parameter values. Parameter values are captured by default per the
/// M4 acceptance criteria; per-target SQL-parameter redaction is applied
/// acceptance criteria; per-target SQL-parameter redaction is applied
/// at write time via <c>AuditLogOptions.PerTargetOverrides.RedactSqlParamsMatching</c>.
/// </summary>
private string? BuildRequestSummary()
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Audit Log #23 — M4 Bundle A: thin decorator over the
/// Thin decorator over the
/// <see cref="DbConnection"/> returned by
/// <see cref="ScriptRuntimeContext.DatabaseHelper.Connection"/>. The decorator
/// itself does no audit work — it simply intercepts
@@ -39,7 +39,7 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
/// ParentExecutionId: the spawning execution's id when this
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
/// alongside <see cref="_executionId"/> into the
/// <see cref="AuditingDbCommand"/> so its <c>DbWrite</c> row stamps it.
@@ -87,13 +87,13 @@ internal sealed class AuditingDbConnection : DbConnection
}
/// <summary>
/// SiteRuntime-022: exposes the wrapped <see cref="DbConnection"/> to the
/// Exposes the wrapped <see cref="DbConnection"/> to the
/// sibling <see cref="AuditingDbCommand"/> in the same assembly, so the
/// command's <c>DbConnection</c> setter can unwrap an
/// <see cref="AuditingDbConnection"/> without reflecting into the
/// private <c>_inner</c> field. Both classes are <c>internal sealed</c>
/// in this assembly, so the accessor stays out of the public API and
/// matches the SiteRuntime-006 precedent of preferring proper API surface
/// matches the precedent of preferring proper API surface
/// over <see cref="System.Reflection"/>.
/// </summary>
internal DbConnection Inner => _inner;
@@ -153,7 +153,7 @@ internal sealed class AuditingDbConnection : DbConnection
_sourceScript,
_logger,
_executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// ParentExecutionId: the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
}
@@ -4,7 +4,7 @@ using System.Data.Common;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Audit Log #23 — M4 Bundle A: <see cref="DbDataReader"/> decorator that
/// <see cref="DbDataReader"/> decorator that
/// counts the number of rows read by the script and fires a single audit
/// emission callback when the reader closes.
/// </summary>
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// disposal — with the running tally. This lets
/// <see cref="AuditingDbCommand"/> emit one
/// <c>DbOutbound</c>/<c>DbWrite</c> row per <c>ExecuteReader</c> with
/// <c>Extra.rowsReturned</c> populated, matching the M4 vocabulary lock.
/// <c>Extra.rowsReturned</c> populated, matching the vocabulary lock.
/// </para>
/// <para>
/// Multiple result sets via <see cref="NextResult"/> are folded into a single
@@ -8,10 +8,10 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
///
/// <para>
/// Thread-model note (SiteRuntime-012): the indexer get/set block synchronously
/// Thread-model note: the indexer get/set block synchronously
/// on the Instance Actor Ask (and, for data-connected attributes, the DCL
/// round-trip). This is safe because script bodies execute on the dedicated
/// <see cref="ScriptExecutionScheduler"/> threads (SiteRuntime-009), not the
/// <see cref="ScriptExecutionScheduler"/> threads, not the
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async
/// variants (<see cref="GetAsync"/>/<see cref="SetAsync"/>) are still preferred
@@ -8,9 +8,9 @@ using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP-19: Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
/// Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
/// The forbidden-API verdict is delegated to the shared authoritative
/// <see cref="ScriptTrustValidator"/> (M3.1 consolidation); this service keeps the real
/// <see cref="ScriptTrustValidator"/>; this service keeps the real
/// execution-path compile of the script against <see cref="ScriptGlobals"/> /
/// <see cref="TriggerExpressionGlobals"/>.
/// </summary>
@@ -26,9 +26,9 @@ public class ScriptCompilationService
}
/// <summary>
/// SiteRuntime-011: validates that the script does not reference forbidden APIs.
/// Validates that the script does not reference forbidden APIs.
///
/// As of the M3.1 script-analysis consolidation this delegates to the shared
/// As of the script-analysis consolidation this delegates to the shared
/// authoritative <see cref="ScriptTrustValidator.FindViolations(string, System.Collections.Generic.IEnumerable{MetadataReference})"/>,
/// which is the same Roslyn semantic-symbol analysis this service previously hosted
/// plus reflection-gateway / <c>dynamic</c> / <c>Activator</c> hardening ported from
@@ -3,7 +3,7 @@ using System.Collections.Concurrent;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// SiteRuntime-009: a dedicated, bounded <see cref="TaskScheduler"/> for running script
/// A dedicated, bounded <see cref="TaskScheduler"/> for running script
/// and alarm on-trigger bodies.
///
/// Script bodies may perform synchronous blocking I/O (a database connection, a
@@ -19,21 +19,21 @@ using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors.
/// Script Runtime API — injected into Script/Alarm Execution Actors.
/// Provides the API surface that user scripts interact with:
/// Instance.GetAttribute("name")
/// Instance.SetAttribute("name", value)
/// Instance.CallScript("scriptName", params)
/// Scripts.CallShared("scriptName", params)
///
/// WP-13 (Phase 7): Integration surface APIs:
/// (Phase 7): Integration surface APIs:
/// ExternalSystem.Call("systemName", "methodName", params)
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// Database.Connection("name")
/// Database.CachedWrite("name", "sql", params)
/// Notify.To("listName").Send("subject", "message")
///
/// WP-20: Recursion Limit — call depth tracked and enforced.
/// Recursion Limit — call depth tracked and enforced.
/// </summary>
public class ScriptRuntimeContext
{
@@ -57,12 +57,12 @@ public class ScriptRuntimeContext
private readonly CancellationToken _scriptTimeoutToken;
/// <summary>
/// WP-13: External system client for ExternalSystem.Call/CachedCall.
/// External system client for ExternalSystem.Call/CachedCall.
/// </summary>
private readonly IExternalSystemClient? _externalSystemClient;
/// <summary>
/// WP-13: Database gateway for Database.Connection/CachedWrite.
/// Database gateway for Database.Connection/CachedWrite.
/// </summary>
private readonly IDatabaseGateway? _databaseGateway;
@@ -84,7 +84,7 @@ public class ScriptRuntimeContext
private readonly string _siteId;
/// <summary>
/// SourceNode-stamping (Task 13/14): the cluster node name supplied by
/// SourceNode-stamping: the cluster node name supplied by
/// <c>INodeIdentityProvider</c> on the local host — <c>node-a</c>/<c>node-b</c>
/// for site nodes. Stamped onto <c>NotificationSubmit.SourceNode</c> by
/// <see cref="NotifyTarget.Send"/> and onto <c>SiteCallOperational.SourceNode</c>
@@ -106,21 +106,21 @@ public class ScriptRuntimeContext
private readonly string? _sourceScript;
/// <summary>
/// M2.12 (#25): site event logger for recording recursion-limit violations
/// Site event logger for recording recursion-limit violations
/// to the local SQLite event log. Optional — when null the emission is
/// skipped; the existing <c>_logger.LogError</c> + throw path is unchanged.
/// </summary>
private readonly ISiteEventLogger? _siteEventLogger;
/// <summary>
/// Audit Log #23: best-effort emitter for boundary-crossing actions executed
/// Best-effort emitter for boundary-crossing actions executed
/// by the script. Optional — when null the helpers degrade to a no-op audit
/// path so tests / contexts that do not need the audit pipeline still work.
/// </summary>
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// Audit Log #23 (M3): site-local tracking store consulted by
/// Site-local tracking store consulted by
/// <c>Tracking.Status(TrackedOperationId)</c>. Optional — when null the
/// helper throws on access, mirroring the existing
/// "service-not-wired" behaviour of the other integration helpers.
@@ -128,7 +128,7 @@ public class ScriptRuntimeContext
private readonly IOperationTrackingStore? _operationTrackingStore;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Task E3): site-side dual emitter for
/// Site-side dual emitter for
/// cached-call lifecycle telemetry. Optional — when null
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> still
/// return a <see cref="TrackedOperationId"/> and invoke the underlying
@@ -138,7 +138,7 @@ public class ScriptRuntimeContext
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// Audit Log #23: the per-execution id for this script run. Every
/// The per-execution id for this script run. Every
/// trust-boundary audit row emitted by this script execution
/// (sync <c>ApiCall</c>/<c>DbWrite</c>, cached-call lifecycle rows,
/// <c>NotifySend</c>) is stamped into <c>AuditEvent.ExecutionId</c> with
@@ -149,15 +149,14 @@ public class ScriptRuntimeContext
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's
/// (ParentExecutionId): the spawning execution's
/// <see cref="_executionId"/> when this script run was spawned by another
/// execution — for an inbound-API-routed call this is the inbound request's
/// per-request execution id. <c>null</c> for normal (tag-change /
/// timer-triggered) runs and nested <c>CallScript</c> invocations. The
/// routed script still mints its OWN fresh <see cref="_executionId"/>; this
/// field records the spawner so a spawned execution's audit rows can point
/// back at the execution that spawned it. (Task 5 wires the emitter that
/// stamps this onto <c>AuditEvent.ParentExecutionId</c>.)
/// back at the execution that spawned it.
/// </summary>
private readonly Guid? _parentExecutionId;
@@ -183,14 +182,14 @@ public class ScriptRuntimeContext
/// <param name="operationTrackingStore">Optional store for tracking cached operation status.</param>
/// <param name="cachedForwarder">Optional forwarder for cached call telemetry.</param>
/// <param name="executionId">
/// Audit Log #23: the per-execution id for this script run. When omitted
/// The per-execution id for this script run. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an
/// inbound caller may supply one to tie the execution to an upstream
/// request. Stamped into <c>AuditEvent.ExecutionId</c> on every
/// trust-boundary audit row this execution emits.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId): the spawning execution's
/// (ParentExecutionId): the spawning execution's
/// <c>ExecutionId</c> — supplied for an inbound-API-routed call (the
/// inbound request's per-request id), <c>null</c> for normal (tag-change /
/// timer-triggered) runs. The routed script still generates its own fresh
@@ -198,7 +197,7 @@ public class ScriptRuntimeContext
/// </param>
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit trail stamping.</param>
/// <param name="siteEventLogger">
/// M2.12 (#25): optional site event logger. When supplied, recursion-limit
/// Optional site event logger. When supplied, recursion-limit
/// violations at <c>CallScript</c> and <c>CallShared</c> emit a
/// <c>script</c> Error event in addition to the existing
/// <c>ILogger.LogError</c> + throw. When null the existing behaviour is
@@ -252,16 +251,16 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
// SourceNode-stamping (Task 13/14): the local node name read from
// SourceNode-stamping: the local node name read from
// INodeIdentityProvider at the ScriptExecutionActor; null when no
// provider was wired so the downstream callsites pass null through
// verbatim — leaving central SourceNode as NULL.
_sourceNode = sourceNode;
_executionId = executionId ?? Guid.NewGuid();
// Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()`
// (ParentExecutionId): stored verbatim — no `?? NewGuid()`
// fallback. A non-routed run legitimately has no parent and stays null.
_parentExecutionId = parentExecutionId;
// M2.12 (#25): optional — null when not wired (tests / AlarmExecutionActor).
// Optional — null when not wired (tests / AlarmExecutionActor).
_siteEventLogger = siteEventLogger;
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
@@ -269,20 +268,20 @@ public class ScriptRuntimeContext
}
/// <summary>
/// Audit Log #23 (M5.4): this run's own per-execution id. Exposed so a
/// This run's own per-execution id. Exposed so a
/// nested <c>Scripts.CallShared</c> can record it as the spawned shared
/// script's <c>ParentExecutionId</c>, forming a true execution tree.
/// </summary>
internal Guid ExecutionId => _executionId;
/// <summary>
/// Audit Log #23 (M5.4): the spawning execution's id for this run (null for
/// The spawning execution's id for this run (null for
/// a root run). Exposed for test assertions on the execution tree.
/// </summary>
internal Guid? ParentExecutionId => _parentExecutionId;
/// <summary>
/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): builds a child
/// (ParentExecutionId tag-cascade): builds a child
/// <see cref="ScriptRuntimeContext"/> for an inline <c>Scripts.CallShared</c>
/// invocation. The shared script runs inline (no actor hop) but is modelled
/// as its OWN execution node in the audit tree: it mints a fresh
@@ -326,9 +325,9 @@ public class ScriptRuntimeContext
}
/// <summary>
/// M2.12 (#25): fire-and-forget emission of a <c>script</c> Error site event
/// Fire-and-forget emission of a <c>script</c> Error site event
/// for a recursion-limit violation. Mirrors the call shape used by
/// <c>ScriptExecutionActor</c>'s catch blocks (WP-32 / M1.8). A fault from
/// <c>ScriptExecutionActor</c>'s catch blocks. A fault from
/// the site-event logger is observed-and-dropped (best-effort) via
/// <c>ContinueWith(OnlyOnFaulted)</c> — it never blocks or faults the
/// <c>_logger.LogError</c> + throw path that follows. A null logger is a no-op.
@@ -565,8 +564,8 @@ public class ScriptRuntimeContext
/// <summary>
/// Calls a sibling script on the same instance by name (Ask pattern).
/// WP-20: Enforces recursion limit.
/// WP-22: Uses Ask pattern for CallScript.
/// Enforces recursion limit.
/// Uses Ask pattern for CallScript.
/// <paramref name="parameters"/> may be a dictionary or an anonymous object
/// (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
/// </summary>
@@ -581,7 +580,7 @@ public class ScriptRuntimeContext
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
_logger.LogError(msg);
// M2.12 (#25): emit to site event log in addition to ILogger; fire-and-forget.
// Emit to site event log in addition to ILogger; fire-and-forget.
EmitRecursionLimitEventAsync(msg);
throw new InvalidOperationException(msg);
}
@@ -592,7 +591,7 @@ public class ScriptRuntimeContext
ScriptArgs.Normalize(parameters),
nextDepth,
correlationId,
// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the child
// (ParentExecutionId tag-cascade): the child
// script run is a NEW execution spawned BY this run. Its parent is
// THIS run's own ExecutionId — NOT the inherited _parentExecutionId.
// So A → CallScript(B) yields B.ParentExecutionId == A.ExecutionId,
@@ -618,25 +617,25 @@ public class ScriptRuntimeContext
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
/// <summary>
/// WP-13: Provides access to external system calls.
/// Provides access to external system calls.
/// ExternalSystem.Call("systemName", "methodName", params)
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary>
public ExternalSystemHelper ExternalSystem => new(
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// Emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue.
_cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId,
// SourceNode-stamping (Task 14): the local node name (node-a/node-b),
// SourceNode-stamping: the local node name (node-a/node-b),
// threaded so the cached-call telemetry construction sites can stamp
// it onto SiteCallOperational.SourceNode.
_sourceNode);
/// <summary>
/// WP-13: Provides access to database operations.
/// Provides access to database operations.
/// Database.Connection("name")
/// Database.CachedWrite("name", "sql", params)
/// </summary>
@@ -645,20 +644,20 @@ public class ScriptRuntimeContext
_instanceName,
_logger,
_executionId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated
// Execute / ExecuteScalar / ExecuteReader.
_auditWriter,
_siteId,
_sourceScript,
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
// Emit CachedSubmit telemetry on
// every Database.CachedWrite enqueue.
_cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId,
// SourceNode-stamping (Task 14): the local node name (node-a/node-b),
// SourceNode-stamping: the local node name (node-a/node-b),
// threaded so Database.CachedWrite's CachedSubmit telemetry can
// stamp it onto SiteCallOperational.SourceNode.
_sourceNode);
@@ -670,7 +669,7 @@ public class ScriptRuntimeContext
/// <c>Notify.Status(id)</c> queries the delivery status of that notification.
/// </summary>
/// <remarks>
/// Audit Log #23 (M4 Bundle C): the <see cref="IAuditWriter"/> is threaded
/// The <see cref="IAuditWriter"/> is threaded
/// through so <c>Notify.To(list).Send(...)</c> emits one
/// <c>Notification</c>/<c>NotifySend</c> audit row per accepted submission.
/// Best-effort per alog.md §7 — a thrown writer never aborts the script's
@@ -679,15 +678,15 @@ public class ScriptRuntimeContext
public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_executionId, _auditWriter,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId,
// SourceNode-stamping (Task 13): the local node name (node-a/node-b),
// SourceNode-stamping: the local node name (node-a/node-b),
// threaded so NotifyTarget.Send can stamp it onto NotificationSubmit.
_sourceNode);
/// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
/// Site-local tracking-status API for cached operations.
/// <c>Tracking.Status(trackedOperationId)</c> reads the site SQLite tracking row
/// directly (authoritative source of truth — no central round-trip) and
/// returns a <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when the
@@ -732,8 +731,8 @@ public class ScriptRuntimeContext
}
/// <summary>
/// WP-17: Executes a shared script inline (direct method call, not actor message).
/// WP-20: Enforces recursion limit.
/// Executes a shared script inline (direct method call, not actor message).
/// Enforces recursion limit.
/// <paramref name="parameters"/> may be a dictionary or an anonymous
/// object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
/// </summary>
@@ -752,13 +751,13 @@ public class ScriptRuntimeContext
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
_logger.LogError(msg);
// M2.12 (#25): emit to site event log via the parent context's
// Emit to site event log via the parent context's
// helper — single emission path, fire-and-forget.
_context.EmitRecursionLimitEventAsync(msg);
throw new InvalidOperationException(msg);
}
// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the shared
// (ParentExecutionId tag-cascade): the shared
// script runs inline, but is modelled as its OWN execution node — a
// child context mints a fresh ExecutionId parented to the caller's
// ExecutionId, so its audit rows chain under the calling run.
@@ -770,10 +769,10 @@ public class ScriptRuntimeContext
}
/// <summary>
/// WP-13: Helper for ExternalSystem.Call/CachedCall syntax.
/// Helper for ExternalSystem.Call/CachedCall syntax.
/// </summary>
/// <remarks>
/// Audit Log #23 (M2 Bundle F): every <see cref="Call"/> invocation emits
/// Every <see cref="Call"/> invocation emits
/// one <c>ApiOutbound</c>/<c>ApiCall</c> audit row via <see cref="IAuditWriter"/>.
/// The audit emission is wrapped in a try/catch that swallows every exception
/// — the audit pipeline is best-effort and must NEVER abort the script's
@@ -795,10 +794,9 @@ public class ScriptRuntimeContext
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// Threaded alongside <see cref="_executionId"/> for audit-row stamping.
/// </summary>
private readonly Guid? _parentExecutionId;
@@ -808,7 +806,7 @@ public class ScriptRuntimeContext
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// SourceNode-stamping (Task 14): the local cluster node name on
/// SourceNode-stamping: the local cluster node name on
/// which this script is executing (<c>node-a</c>/<c>node-b</c>).
/// Stamped onto <c>SiteCallOperational.SourceNode</c> on the three
/// cached-call telemetry construction sites (CachedSubmit + the two
@@ -883,7 +881,7 @@ public class ScriptRuntimeContext
if (_client == null)
throw new InvalidOperationException("External system client not available");
// Audit Log #23 (M2 Bundle F): wrap the outbound call so every
// Wrap the outbound call so every
// attempt emits exactly one ApiOutbound/ApiCall row. The wrapper
// mirrors the existing call-site behaviour — the original result
// OR original exception flows back to the script untouched; the
@@ -911,11 +909,11 @@ public class ScriptRuntimeContext
}
/// <summary>
/// Submit a cached outbound API call (Audit Log #23 / M3). Mints a
/// Submit a cached outbound API call. Mints a
/// fresh <see cref="TrackedOperationId"/>, emits the lifecycle's first
/// <c>CachedSubmit</c> telemetry packet, hands the call to the
/// store-and-forward retry loop (which emits per-attempt and terminal
/// telemetry under the same id — Bundle E Tasks E4/E5), and returns
/// telemetry under the same id), and returns
/// the id immediately so the script can later query
/// <c>Tracking.Status(id)</c>.
/// </summary>
@@ -953,15 +951,15 @@ public class ScriptRuntimeContext
.ConfigureAwait(false);
// Hand off to the existing cached-call path. The TrackedOperationId
// becomes the S&F message id so the retry loop (Bundle E Tasks
// E4/E5) can read it back via StoreAndForwardMessage.Id.
// becomes the S&F message id so the retry loop can read it back
// via StoreAndForwardMessage.Id.
//
// M3 Bundle F (F2): the result is now retained because the
// The result is now retained because the
// immediate-success path (WasBuffered=false) bypasses S&F entirely
// — no retry loop, no ICachedCallLifecycleObserver fire. The
// helper must emit the Attempted + CachedResolve terminal rows
// itself, otherwise Tracking.Status(id) would stay in Submitted
// forever and the audit log would be missing the M3 lifecycle.
// forever and the audit log would be missing the lifecycle.
// The WasBuffered=true path is unaffected — the S&F retry loop
// owns the Attempted + Resolve emissions in that case.
ExternalCallResult? result;
@@ -974,12 +972,12 @@ public class ScriptRuntimeContext
_instanceName,
cancellationToken,
trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// (ExecutionId): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached call's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript,
// Audit Log #23 (ParentExecutionId Task 6): thread the
// (ParentExecutionId): thread the
// spawning inbound-API request's ExecutionId so a buffered
// cached call's retry-loop audit rows carry it too. Null
// for a non-routed run.
@@ -998,7 +996,7 @@ public class ScriptRuntimeContext
throw;
}
// M3 Bundle F (F2): immediate-completion lifecycle — emit the
// Immediate-completion lifecycle — emit the
// missing Attempted + CachedResolve rows when the underlying call
// resolved without engaging the store-and-forward retry loop.
if (result is { WasBuffered: false })
@@ -1045,7 +1043,7 @@ public class ScriptRuntimeContext
// per-execution id shared across this script run.
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -1058,7 +1056,7 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode-stamping (Task 14): the local node name
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
@@ -1093,11 +1091,11 @@ public class ScriptRuntimeContext
}
/// <summary>
/// M3 Bundle F (F2): emit the Attempted + CachedResolve lifecycle
/// Emit the Attempted + CachedResolve lifecycle
/// rows for an immediate-completion <c>CachedCall</c> (WasBuffered=false).
/// The S&amp;F retry loop never engaged, so the
/// <c>ICachedCallLifecycleObserver</c> never fires — the helper must
/// produce both rows itself to keep the M3 audit contract whole
/// produce both rows itself to keep the audit contract whole
/// (Submit → Attempted → Resolve under one TrackedOperationId).
/// </summary>
/// <remarks>
@@ -1162,7 +1160,7 @@ public class ScriptRuntimeContext
// ExecutionId = per-execution id for this script run.
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -1177,7 +1175,7 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode-stamping (Task 14): the local node name
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
@@ -1230,7 +1228,7 @@ public class ScriptRuntimeContext
// ExecutionId = per-execution id for this script run.
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -1245,7 +1243,7 @@ public class ScriptRuntimeContext
Channel: "ApiOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode-stamping (Task 14): the local node name
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
@@ -1367,7 +1365,7 @@ public class ScriptRuntimeContext
// Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient
// exceptions into Success=false on the result, or surfaces a raw
// exception). M2 makes no distinction between transient + permanent
// exception). No distinction is made between transient + permanent
// failure here — both manifest as Status.Failed on the sync path.
var status = (thrown == null && result != null && result.Success)
? AuditStatus.Delivered
@@ -1409,13 +1407,13 @@ public class ScriptRuntimeContext
// (e.g. a shared script running inline).
actor: _sourceScript,
target: $"{systemName}.{methodName}",
// Audit Log #23: a sync one-shot call has no operation
// A sync one-shot call has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so all the sync ApiCall/DbWrite rows from
// one script run can be correlated together.
correlationId: null,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// (ParentExecutionId): the spawning execution's
// id; null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -1461,10 +1459,10 @@ public class ScriptRuntimeContext
}
/// <summary>
/// WP-13: Helper for Database.Connection/CachedWrite syntax.
/// Helper for Database.Connection/CachedWrite syntax.
/// </summary>
/// <remarks>
/// Audit Log #23 (M3 Bundle E — Task E6): <see cref="CachedWrite"/> mirrors
/// <see cref="CachedWrite"/> mirrors
/// <see cref="ExternalSystemHelper.CachedCall"/> — mints a
/// <see cref="TrackedOperationId"/>, emits the lifecycle's first
/// CachedSubmit packet (Channel <c>DbOutbound</c>), hands off to the S&amp;F
@@ -1479,10 +1477,9 @@ public class ScriptRuntimeContext
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// Threaded alongside <see cref="_executionId"/> for audit-row stamping.
/// </summary>
private readonly Guid? _parentExecutionId;
@@ -1491,7 +1488,7 @@ public class ScriptRuntimeContext
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// Audit Log #23 (M4 Bundle A): best-effort emitter for synchronous
/// Best-effort emitter for synchronous
/// <c>Database.Connection</c>-routed Execute / ExecuteScalar /
/// ExecuteReader calls. When wired, <see cref="Connection"/> returns
/// an <see cref="AuditingDbConnection"/> that intercepts each command
@@ -1503,7 +1500,7 @@ public class ScriptRuntimeContext
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// SourceNode-stamping (Task 14): the local cluster node name on
/// SourceNode-stamping: the local cluster node name on
/// which this script is executing (<c>node-a</c>/<c>node-b</c>).
/// Stamped onto <c>SiteCallOperational.SourceNode</c> at the
/// <c>Database.CachedWrite</c> CachedSubmit telemetry construction
@@ -1567,7 +1564,7 @@ public class ScriptRuntimeContext
var inner = await _gateway.GetConnectionAsync(name, cancellationToken);
// Audit Log #23 (M4 Bundle A): wrap in an auditing decorator so
// Wrap in an auditing decorator so
// every script-initiated Execute* / ExecuteReader on the returned
// connection emits one DbOutbound/DbWrite audit row. The wrapper
// delegates all other ADO.NET behaviour to the inner connection
@@ -1588,7 +1585,7 @@ public class ScriptRuntimeContext
sourceScript: _sourceScript,
logger: _logger,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// (ParentExecutionId): the spawning execution's
// id, threaded alongside _executionId. Null for non-routed runs.
parentExecutionId: _parentExecutionId);
}
@@ -1623,7 +1620,7 @@ public class ScriptRuntimeContext
name, trackedId, target, occurredAtUtc, cancellationToken)
.ConfigureAwait(false);
// M2.3 (#7): the gateway now attempts the write immediately and
// The gateway now attempts the write immediately and
// classifies the outcome (mirroring ExternalSystem.CachedCall). The
// result is retained because the immediate paths (WasBuffered=false —
// immediate success OR a synchronous permanent failure) bypass the
@@ -1638,12 +1635,12 @@ public class ScriptRuntimeContext
{
result = await _gateway.CachedWriteAsync(
name, sql, parameters, _instanceName, cancellationToken, trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// (ExecutionId): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached write's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript,
// Audit Log #23 (ParentExecutionId Task 6): thread the
// (ParentExecutionId): thread the
// spawning inbound-API request's ExecutionId so a buffered
// cached write's retry-loop audit rows carry it too. Null
// for a non-routed run.
@@ -1658,7 +1655,7 @@ public class ScriptRuntimeContext
throw;
}
// M2.3 (#7): immediate-completion lifecycle — emit the missing
// Immediate-completion lifecycle — emit the missing
// Attempted + CachedResolve rows when the underlying write resolved
// without engaging the store-and-forward retry loop (immediate
// success or a synchronous permanent failure).
@@ -1673,7 +1670,7 @@ public class ScriptRuntimeContext
}
/// <summary>
/// M2.3 (#7): best-effort emission of the immediate-completion lifecycle
/// Best-effort emission of the immediate-completion lifecycle
/// for a <c>Database.CachedWrite</c> that resolved without the S&amp;F
/// retry loop — emits an <c>Attempted</c> row then a terminal
/// <c>CachedResolve</c> row (<c>Delivered</c> on success, <c>Failed</c> on
@@ -1826,7 +1823,7 @@ public class ScriptRuntimeContext
// (TrackedOperationId); ExecutionId = per-execution id.
correlationId: trackedId.Value,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -1837,7 +1834,7 @@ public class ScriptRuntimeContext
Channel: "DbOutbound",
Target: target,
SourceSite: _siteId,
// SourceNode-stamping (Task 14): the local node name
// SourceNode-stamping: the local node name
// (node-a/node-b) — threaded through INodeIdentityProvider
// at the ScriptExecutionActor; null when no provider was
// wired so central persists SiteCalls.SourceNode as NULL.
@@ -1892,31 +1889,30 @@ public class ScriptRuntimeContext
private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// The per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// Threaded alongside <see cref="_executionId"/> for audit-row stamping.
/// </summary>
private readonly Guid? _parentExecutionId;
/// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// Best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script
/// calls <c>Notify.To(list).Send(...)</c>. Optional — when null the
/// <see cref="NotifyTarget"/> degrades to a no-op audit path so tests
/// / minimal hosts that don't wire AddAuditLog still work (mirrors the
/// M2 Bundle F <c>IExternalSystemClient</c> wrapper).
/// <c>IExternalSystemClient</c> wrapper).
/// </summary>
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// SourceNode-stamping (Task 13): the cluster node name on which this
/// SourceNode-stamping: the cluster node name on which this
/// script is executing — <c>node-a</c>/<c>node-b</c>. Stamped onto
/// <c>NotificationSubmit.SourceNode</c> by <see cref="NotifyTarget.Send"/>
/// so central can persist it on the <c>Notifications</c> row.
@@ -1975,16 +1971,16 @@ public class ScriptRuntimeContext
{
return new NotifyTarget(
listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger,
// Audit Log #23: the per-execution id stamped into the
// The per-execution id stamped into the
// NotifySend row's ExecutionId column.
_executionId,
// Audit Log #23 (M4 Bundle C): forward the writer so Send()
// Forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission.
_auditWriter,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// (ParentExecutionId): the spawning execution's
// id, threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId,
// SourceNode-stamping (Task 13): the local node name, stamped
// SourceNode-stamping: the local node name, stamped
// onto NotificationSubmit.SourceNode in Send().
_sourceNode);
}
@@ -2064,21 +2060,20 @@ public class ScriptRuntimeContext
private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// The per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// Threaded alongside <see cref="_executionId"/> for audit-row stamping.
/// </summary>
private readonly Guid? _parentExecutionId;
/// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// Best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after
/// the underlying S&amp;F enqueue accepts the submission. Optional —
/// when null no audit row is emitted (no-op path).
@@ -2086,7 +2081,7 @@ public class ScriptRuntimeContext
private readonly IAuditWriter? _auditWriter;
/// <summary>
/// SourceNode-stamping (Task 13): the cluster node name on which this
/// SourceNode-stamping: the cluster node name on which this
/// script is executing (<c>node-a</c>/<c>node-b</c>). Stamped onto the
/// <c>NotificationSubmit.SourceNode</c> field in <see cref="Send"/> so
/// the central <c>NotificationOutboxActor</c> can persist it on the
@@ -2175,18 +2170,18 @@ public class ScriptRuntimeContext
// central audit trail. Null when no single script owns the context.
SourceScript: _sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow,
// OriginExecutionId (Audit Log #23): the SAME per-execution id stamped
// OriginExecutionId: the SAME per-execution id stamped
// onto this run's NotifySend audit row. It rides inside the serialized
// payload through the S&F buffer to central, where the dispatcher echoes
// it onto the NotifyDeliver rows so all rows for one run share an id.
OriginExecutionId: _executionId,
// OriginParentExecutionId (Audit Log #23): the SAME parent-execution id
// OriginParentExecutionId: the SAME parent-execution id
// stamped onto this run's NotifySend audit row — the spawning run's id
// for an inbound-API-routed execution, null otherwise. It rides through
// the S&F buffer to central, where the dispatcher echoes it onto the
// NotifyDeliver rows so the central rows carry the routed run's parent id.
OriginParentExecutionId: _parentExecutionId,
// SourceNode-stamping (Task 13): the cluster node name on which this
// SourceNode-stamping: the cluster node name on which this
// notification was emitted (node-a/node-b). Stamped from the local
// INodeIdentityProvider via ScriptExecutionActor. Rides inside the
// serialized payload through the S&F buffer to central, where
@@ -2210,7 +2205,7 @@ public class ScriptRuntimeContext
"Notify enqueued notification {NotificationId} to list '{List}' for central delivery",
notificationId, _listName);
// Audit Log #23 (M4 Bundle C): emit one Notification/NotifySend
// Emit one Notification/NotifySend
// (Submitted) row per accepted submission. The emission is wired
// AFTER the EnqueueAsync returns so we only audit submissions the
// S&F engine accepted — a failed enqueue throws, never produces an
@@ -2248,7 +2243,7 @@ public class ScriptRuntimeContext
// mints the id via Guid.NewGuid().ToString("N") so the parse
// is expected to succeed; on the off-chance the format
// changes / a caller injects an unparseable value, leave it
// null per Bundle B's pattern rather than fail the emission.
// null rather than fail the emission.
Guid? correlationId = Guid.TryParse(notificationId, out var parsed) ? parsed : (Guid?)null;
// Capture the request summary — {"subject": "...", "body": "..."}.
@@ -2274,7 +2269,7 @@ public class ScriptRuntimeContext
// lifecycle id; ExecutionId carries the per-execution id.
correlationId: correlationId,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
@@ -2338,7 +2333,7 @@ public class ScriptRuntimeContext
}
/// <summary>
/// Audit Log #23 (M3): script-side accessor for cached-operation tracking.
/// Script-side accessor for cached-operation tracking.
/// <c>Tracking.Status(trackedOperationId)</c> reads the site-local SQLite
/// row directly via <see cref="IOperationTrackingStore.GetStatusAsync"/> —
/// the site is the single source of truth for cached-call status, so no
@@ -5,10 +5,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP-17: Shared Script Library — stores compiled shared script delegates in memory.
/// Shared Script Library — stores compiled shared script delegates in memory.
/// Shared scripts are compiled when received from central and executed inline
/// (direct method call, not actor message). NOT available on central.
/// WP-33: Recompiled on update when new artifacts arrive.
/// Recompiled on update when new artifacts arrive.
/// </summary>
public class SharedScriptLibrary
{
@@ -43,13 +43,13 @@ public static class ServiceCollectionExtensions
services.AddHostedService<SiteStorageInitializer>();
// WP-19: Script compilation service
// Script compilation service
services.AddSingleton<ScriptCompilationService>();
// WP-17: Shared script library
// Shared script library
services.AddSingleton<SharedScriptLibrary>();
// WP-23: Site stream manager — registered as singleton and exposed as ISiteStreamSubscriber
// Site stream manager — registered as singleton and exposed as ISiteStreamSubscriber
// so the gRPC server can subscribe relay actors to instance events.
// ActorSystem is injected later via Initialize() after AkkaHostedService starts.
services.AddSingleton(sp =>
@@ -38,7 +38,7 @@ public class SiteRuntimeOptions
public int StreamBufferSize { get; set; } = 1000;
/// <summary>
/// SiteRuntime-009: number of dedicated threads in the script-execution scheduler.
/// Number of dedicated threads in the script-execution scheduler.
/// Script and alarm on-trigger bodies run on these threads instead of the shared
/// .NET thread pool, so blocking script I/O cannot starve the global pool.
/// Default: 8.
@@ -9,12 +9,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
/// <summary>
/// WP-23: Site-Wide Akka Stream — manages a broadcast stream for attribute value
/// Site-Wide Akka Stream — manages a broadcast stream for attribute value
/// and alarm state changes. Instance Actors publish events via fire-and-forget Tell.
/// A BroadcastHub fans events out to per-subscriber graphs, each filtered by
/// instance name and bounded by a drop-oldest buffer.
///
/// Filterable by instance name for debug view (WP-25).
/// Filterable by instance name for debug view.
/// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors
/// to instance events without referencing SiteRuntime directly.
/// </summary>
@@ -118,7 +118,7 @@ public class SiteStreamManager : ISiteStreamSubscriber
}
/// <summary>
/// WP-25: Unsubscribe from instance events. Shuts down the per-subscriber
/// Unsubscribe from instance events. Shuts down the per-subscriber
/// stream graph via its KillSwitch.
/// </summary>
/// <param name="subscriptionId">The subscription ID returned by <see cref="Subscribe"/>.</param>
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
/// <summary>
/// Options for <see cref="OperationTrackingStore"/> — site-local cached-call
/// tracking SQLite store (Audit Log #23 / M3).
/// tracking SQLite store.
/// </summary>
public class OperationTrackingOptions
{
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
/// <summary>
/// Site-local SQLite source-of-truth for cached-operation tracking — the row
/// that <c>Tracking.Status(TrackedOperationId)</c> reads (Audit Log #23 / M3).
/// that <c>Tracking.Status(TrackedOperationId)</c> reads.
/// </summary>
/// <remarks>
/// <para>
@@ -20,10 +20,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
/// only cached calls produce rows, and only a handful of lifecycle events per
/// call — so we keep the implementation deliberately simple: a single owned
/// <see cref="SqliteConnection"/> serialised behind a <see cref="SemaphoreSlim"/>
/// (one async writer at a time). This is the pattern the M3 brief calls out as
/// "cleaner than the M2 Channel&lt;T&gt; pipeline given the volume"; the M2
/// audit-writer's batched-channel design is reserved for the high-volume audit
/// hot-path.
/// (one async writer at a time) — simpler than a batched-channel pipeline given
/// the volume; the batched-channel audit-writer design is reserved for the
/// high-volume audit hot-path.
/// </para>
/// <para>
/// All mutations are idempotent / monotonic: <see cref="RecordEnqueueAsync"/> is
@@ -36,14 +35,14 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tracking;
/// </remarks>
public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable, IDisposable
{
// SiteRuntime-024: writer state — one owned SqliteConnection serialised behind
// Writer state — one owned SqliteConnection serialised behind
// _writeGate. Readers do NOT share this connection or gate; see GetStatusAsync.
private readonly SqliteConnection _writeConnection;
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly string _connectionString;
private readonly ILogger<OperationTrackingStore> _logger;
// SiteRuntime-024: dispose-once state shared by the sync Dispose and async
// Dispose-once state shared by the sync Dispose and async
// DisposeAsync paths. Interlocked.Exchange is the race-safe primitive here —
// a plain bool can be flipped twice if Dispose() and DisposeAsync() are
// invoked concurrently (e.g. host shutdown bridging both). 0 = live,
@@ -290,7 +289,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
// SiteRuntime-024: reads open a fresh, ungated SqliteConnection so a
// Reads open a fresh, ungated SqliteConnection so a
// long-running write doesn't block status queries. The connection
// string is shared with the writer; SQLite handles cross-connection
// isolation natively (a reader sees a consistent snapshot via the
@@ -370,7 +369,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeState) != 0, this);
// SiteRuntime-024: like GetStatusAsync, the reconciliation pull opens a
// Like GetStatusAsync, the reconciliation pull opens a
// fresh, ungated read connection so a long-running write never blocks
// central's PullSiteCalls. The query is a bounded, ordered scan served by
// the standalone IX_OperationTracking_UpdatedAt index — UpdatedAtUtc is
@@ -452,7 +451,7 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
/// Synchronously disposes the tracking store and its SQLite connection.
/// </summary>
/// <remarks>
/// SiteRuntime-024: this path does NOT bridge to async via
/// This path does NOT bridge to async via
/// <c>.AsTask().GetAwaiter().GetResult()</c>. Sync-over-async on a SemaphoreSlim
/// can deadlock when invoked from a non-reentrant SyncContext (e.g. host
/// shutdown continuations observed on the host sync context). In-flight writes
@@ -24,7 +24,7 @@
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests" />
<!--
Audit Log #23 (M4 Bundle E — Task E1): the cross-project
The cross-project
DatabaseSyncEmissionEndToEndTests construct ScriptRuntimeContext.DatabaseHelper
directly (it has an internal ctor) so the test can drive the production
AuditingDbConnection wrapper end-to-end against a real MSSQL central