docs(src): fix CommentChecker findings across src/ — 1,032 → 5 (all false positives)

Comment/XML-doc changes only, no code logic:
- strip project-tracking references (WP*, P*-*, row-NN, #NN, Task N) from
  comments, keeping the explanatory rationale
- add missing summaries, <param>, <returns>, <typeparam> and <inheritdoc/>
- replace misused <inheritdoc/> with real docs; where <inheritdoc/> was
  combined with useful remarks, move the rationale into // comments
- drop stale replicationActor <param> on DeploymentManagerActor ctor

Remaining 5 are false positives (CWE-1236 x2, failures-1, year-0001, M365).
Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 14:12:25 -04:00
parent 3a217b9484
commit 0ffd4bdbea
217 changed files with 1224 additions and 695 deletions
@@ -42,34 +42,34 @@ public class AlarmActor : ReceiveActor
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide
/// Script-execution scheduler seam: the process-wide
/// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so this
/// alarm's launched on-trigger scripts run on a caller-owned pool. Resolved lazily at
/// each use so the null (host) path is unchanged.
///
/// <para>WP3.1: trigger-expression evaluation no longer uses this scheduler — see
/// <see cref="_evalGate"/>. That split is the whole point of finding #4: an alarm whose
/// <para>Trigger-expression evaluation no longer uses this scheduler — see
/// <see cref="_evalGate"/>. That split is the whole point: an alarm whose
/// Expression trigger should raise in milliseconds must not queue behind blocking
/// script bodies.</para>
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
/// <summary>
/// WP3.1 (finding #4): the concurrency gate for trigger-expression evaluation, or null
/// The concurrency gate for trigger-expression evaluation, or null
/// for the process-wide <see cref="TriggerEvalGate.Shared"/>. Evaluations run as plain
/// async work on the shared .NET thread pool behind this gate.
/// </summary>
private readonly TriggerEvalGate? _evalGate;
/// <summary>
/// WP3.1: on-trigger runs launched but not yet completed. Incremented at launch,
/// On-trigger runs launched but not yet completed. Incremented at launch,
/// decremented on <see cref="AlarmExecutionCompleted"/>, which every terminal path
/// emits — including the launch-path catch. Touched only on the actor thread.
/// </summary>
private int _runsInFlight;
/// <summary>
/// WP3.1: when the last shed Warning site event was emitted for this alarm. Sheds are
/// When the last shed Warning site event was emitted for this alarm. Sheds are
/// always counted; the event is rate-limited to one per alarm per minute.
/// </summary>
private DateTimeOffset _lastShedEventUtc = DateTimeOffset.MinValue;
@@ -130,8 +130,7 @@ public class AlarmActor : ReceiveActor
private bool _evalPending;
/// <summary>
/// Audit Log #23 (ParentExecutionId tag-cascade): the
/// <see cref="AttributeValueChanged.SourceExecutionId"/> of the most recent
/// The <see cref="AttributeValueChanged.SourceExecutionId"/> of the most recent
/// change folded into <see cref="_attributeSnapshot"/>. Expression triggers
/// evaluate a whole snapshot off the dispatcher, so there is no single
/// "triggering change" in scope when the boolean result comes back — this
@@ -150,8 +149,7 @@ public class AlarmActor : ReceiveActor
internal IReadOnlyDictionary<string, object?>? SeedAttributesReference { get; }
/// <summary>
/// Audit Log #23 (ParentExecutionId tag-cascade): the
/// <c>parentExecutionId</c> handed to the most recently spawned
/// The <c>parentExecutionId</c> handed to the most recently spawned
/// on-trigger run — i.e. the execution whose attribute
/// write fired this alarm, or <c>null</c> when the firing change came from
/// the Data Connection Layer (external data has no spawning execution).
@@ -185,8 +183,8 @@ public class AlarmActor : ReceiveActor
/// <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>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
/// <param name="evalGate">Optional trigger-expression concurrency gate override (WP3.1); null uses the process-wide shared gate.</param>
/// <param name="scheduler">Optional script-execution scheduler override; null uses the process-wide shared scheduler.</param>
/// <param name="evalGate">Optional trigger-expression concurrency gate override; null uses the process-wide shared gate.</param>
public AlarmActor(
string alarmName,
string instanceName,
@@ -202,9 +200,9 @@ public class AlarmActor : ReceiveActor
IServiceProvider? serviceProvider = null,
// Per-script timeout for the on-trigger script (null = global).
int? onTriggerExecutionTimeoutSeconds = null,
// Script-execution scheduler seam (#18); null uses the process-wide shared scheduler.
// Script-execution scheduler seam; null uses the process-wide shared scheduler.
ScriptExecutionScheduler? scheduler = null,
// WP3.1 trigger-eval gate seam; null uses the process-wide shared gate.
// Trigger-eval gate seam; null uses the process-wide shared gate.
TriggerEvalGate? evalGate = null)
{
_alarmName = alarmName;
@@ -248,7 +246,7 @@ public class AlarmActor : ReceiveActor
// Handle attribute value changes
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
// Handle alarm execution completion (also releases the WP3.1 in-flight slot)
// Handle alarm execution completion (also releases the in-flight slot)
Receive<AlarmExecutionCompleted>(HandleAlarmExecutionCompleted);
// Handle the off-dispatcher trigger-expression evaluation result (P1).
@@ -268,7 +266,7 @@ public class AlarmActor : ReceiveActor
_alarmName, _instanceName, _triggerType);
}
// WP3.1: the Stop-on-failure SupervisorStrategy override is gone with the per-run
// The Stop-on-failure SupervisorStrategy override is gone with the per-run
// AlarmExecutionActor child it supervised. This actor has no children left; a
// launch-path throw is caught in SpawnAlarmExecution so it can never escalate to
// InstanceActor, which continues to supervise this actor with Resume exactly as before.
@@ -613,7 +611,7 @@ public class AlarmActor : ReceiveActor
// mis-attribute the raise this evaluation produces.
var sourceExecutionId = _latestSourceExecutionId;
var gate = _evalGate ?? TriggerEvalGate.Shared(_options);
// WP3.1 (finding #4): the deadline clock starts AT ENQUEUE, on the actor thread —
// The deadline clock starts AT ENQUEUE, on the actor thread —
// gate-wait time burns the same budget, so a saturated gate yields a timely false
// instead of an unbounded stall. And the work runs on the shared .NET thread pool,
// NOT the blocking script pool, so it can never queue behind a blocked script body.
@@ -747,7 +745,7 @@ public class AlarmActor : ReceiveActor
}
/// <summary>
/// WP3.1: releases the in-flight slot for a completed on-trigger run. Every terminal
/// Releases the in-flight slot for a completed on-trigger run. Every terminal
/// path emits exactly one <see cref="AlarmExecutionCompleted"/> (success / timeout /
/// failure / launch failure), so the counter tracks reality.
/// </summary>
@@ -760,7 +758,7 @@ public class AlarmActor : ReceiveActor
}
/// <summary>
/// WP3.1: on-trigger runs launched but not yet completed. Exposed for regression
/// On-trigger runs launched but not yet completed. Exposed for regression
/// coverage of the shed cap.
/// </summary>
internal int RunsInFlight => _runsInFlight;
@@ -770,7 +768,7 @@ public class AlarmActor : ReceiveActor
/// Passes the firing alarm's level/priority/message so the script can
/// branch on severity via the <c>Alarm</c> global.
///
/// <para>WP3.1: launched directly via <see cref="ScriptRunLauncher"/> rather than
/// <para>Launched directly via <see cref="ScriptRunLauncher"/> rather than
/// through a short-lived <c>AlarmExecutionActor</c> child, and bounded by
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> — a raise arriving while
/// the cap is reached is shed (counted, rate-limited site event) rather than piling
@@ -828,7 +826,7 @@ public class AlarmActor : ReceiveActor
// Context.Parent.Tell from the execution actor.
Self,
_logger,
// arch-review 2026-08-19 P1-3: same health collector + service provider as
// Same health collector + service provider as
// the instance-script path, so the alarm on-trigger run gets the same
// stuck-script watchdog, script-error counting, and run-summary threading.
_healthCollector,
@@ -838,13 +836,13 @@ public class AlarmActor : ReceiveActor
_onTriggerExecutionTimeoutSeconds,
// The firing execution's id — null for DCL-originated changes.
parentExecutionId,
// Scheduler seam (#18): share this alarm's scheduler override with the
// Scheduler seam: share this alarm's scheduler override with the
// launched on-trigger script body (null = process-wide shared).
_scheduler, Context.System.ActorSelection("/user/site-communication"));
}
catch (Exception ex)
{
// WP3.1 supervision parity: mirrors the removed OneForOneStrategy's warn-and-stop
// Supervision parity: mirrors the removed OneForOneStrategy's warn-and-stop
// for a per-run child that failed to construct. The alarm continues.
_logger.LogWarning(ex,
"Alarm on-trigger execution launch for {Alarm} on {Instance} failed, stopping",
@@ -854,7 +852,7 @@ public class AlarmActor : ReceiveActor
}
/// <summary>
/// WP3.1 shed policy for alarm on-trigger runs: refuses the newest run when
/// Shed policy for alarm on-trigger runs: refuses the newest run when
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> are already in flight.
/// There is no Ask caller on this path (an on-trigger run is never awaited), so the
/// shed surfaces as a counter plus a rate-limited Warning site event.
@@ -43,7 +43,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly DeployCompileValidator _deployCompileValidator;
private readonly SharedScriptLibrary _sharedScriptLibrary;
/// <summary>
/// WP2.6a: the shared cache backing <c>SiteExternalSystemRepository</c> reads.
/// The shared cache backing <c>SiteExternalSystemRepository</c> reads.
/// Invalidated wholesale after <see cref="HandleDeployArtifacts"/> applies
/// external-system changes so a script's next call re-reads fresh definitions/method
/// lists instead of serving a stale cached snapshot. Null in tests that do not wire
@@ -51,7 +51,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// </summary>
private readonly ExternalSystemDefinitionCache? _externalSystemCache;
/// <summary>
/// Task 10 (site-secrets artifact deploy): per-node mirror reconciled synchronously by
/// Per-node mirror reconciled synchronously by
/// <see cref="HandleDeployArtifacts"/> right after the shipped secrets are written to
/// <c>deployed_secrets</c>, so the reply's <c>SecretsApplied</c> reflects this node's own
/// apply/verify outcome rather than the next periodic pass. Optional/null on nodes/tests
@@ -97,7 +97,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// test can hold the read open mid-flight (row 101's ensure-started race tests).
/// </summary>
/// <remarks>
/// Register P1-4: the row-67 ensure-started heal used to read through
/// The ensure-started heal used to read through
/// <see cref="_configLoader"/> — the same full-table-including-ConfigJson load the boot
/// sequence uses — just to <c>FirstOrDefault</c> one instance. A volume-restore reconcile
/// that raises hundreds of <see cref="EnsureInstanceStartedCommand"/>s in a row turned that
@@ -202,7 +202,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly HashSet<string> _initFailedPendingRowRemoval = new();
/// <summary>
/// WP3.1 warm-then-gate: instances whose deploy compile is being warmed off the actor
/// Warm-then-gate: instances whose deploy compile is being warmed off the actor
/// thread, keyed by instance name. Presence is the per-instance in-flight guard that keeps
/// same-instance command ordering intact while OTHER instances' commands flow freely.
/// Entries are added in <see cref="HandleDeploy"/> and removed in
@@ -212,7 +212,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
private readonly Dictionary<string, DeployWarmState> _deployWarms = new();
/// <summary>
/// WP3.1: script-execution scheduler seam (#18) — the pool this actor grows as instances
/// Script-execution scheduler seam — the pool this actor grows as instances
/// deploy. Null selects the process-wide shared scheduler; tests inject their own so
/// sizing assertions do not perturb (or depend on) the process-wide singleton.
/// </summary>
@@ -236,7 +236,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// The stored revision hash the instance reconcile sweep has already ACTED on for an
/// instance, keyed by instance name — <b>one repair attempt per (instance, revision)
/// pair</b>, for both the row-78 start and the row-72 restart.
/// pair</b>, for both the reconcile-sweep start and the snapshot-driven restart.
/// <para>
/// This is what keeps a periodic self-heal from becoming a restart loop. An instance whose
/// Instance Actor dies during init leaves its config row in place, so without this the
@@ -281,7 +281,6 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <param name="options">Site runtime options (startup batch size, stagger interval, etc.).</param>
/// <param name="logger">Logger instance.</param>
/// <param name="dclManager">Optional Data Connection Layer manager actor.</param>
/// <param name="replicationActor">Optional standby replication actor.</param>
/// <param name="healthCollector">Optional site health collector.</param>
/// <param name="serviceProvider">Optional service provider for resolving per-instance services.</param>
/// <param name="loggerFactory">Optional logger factory for creating Instance Actor loggers.</param>
@@ -295,13 +294,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <param name="configLoader">Optional override for loading all deployed configurations
/// at startup; defaults to reading from <paramref name="storage"/>. Primarily for tests.</param>
/// <param name="externalSystemCache">
/// WP2.6a: the shared <see cref="ExternalSystemDefinitionCache"/> backing
/// The shared <see cref="ExternalSystemDefinitionCache"/> backing
/// <c>SiteExternalSystemRepository</c> reads, invalidated after this actor applies
/// external-system changes. Optional/null in tests that do not exercise external-system
/// caching.
/// </param>
/// <param name="scriptScheduler">
/// WP3.1: optional script-execution scheduler override (#18). This actor grows the pool
/// Optional script-execution scheduler override. This actor grows the pool
/// towards <see cref="ScriptExecutionScheduler.ComputeTargetThreads"/> on every instance-
/// count change; null uses the process-wide shared scheduler.
/// </param>
@@ -312,12 +311,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// drive a read failure.
/// </param>
/// <param name="deployedConfigLoader">
/// Register P1-4: optional override for the single-row deployed-config read the row-67
/// Optional override for the single-row deployed-config read the
/// ensure-started heal uses; defaults to reading from <paramref name="storage"/>. Primarily
/// for tests that need to hold the read open mid-flight to drive the row-101 race.
/// for tests that need to hold the read open mid-flight to drive the freshness race.
/// </param>
/// <param name="secretsMirror">
/// Task 10 (site-secrets artifact deploy): this node's <see cref="DeployedSecretsMirror"/>,
/// This node's <see cref="DeployedSecretsMirror"/>,
/// reconciled synchronously by <see cref="HandleDeployArtifacts"/> right after a shipped
/// <c>command.Secrets</c> set is written to <c>deployed_secrets</c>. Optional/null on
/// nodes/tests that never register secret deployment.
@@ -378,10 +377,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<EnableInstanceCommand>(cmd => HandleEnable(cmd, Sender));
Receive<DeleteInstanceCommand>(cmd => HandleDelete(cmd, Sender));
// WP3.1 warm-then-gate: the off-thread compile warm for a deploy has finished, so
// Warm-then-gate: the off-thread compile warm for a deploy has finished, so
// the (now all-cache-hit) synchronous compile gate can run on the actor thread.
Receive<DeployCompileWarmed>(HandleDeployCompileWarmed);
// WP3.1: a staggered startup batch's compiles have been pre-warmed off-thread;
// A staggered startup batch's compiles have been pre-warmed off-thread;
// create the batch's Instance Actors now that their PreStart compiles are hits.
Receive<BatchCompileWarmed>(msg => CreateInstanceActorBatch(msg.Batch));
@@ -732,7 +731,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Rows 89 + 90: seed the DCL arbitration overlay BEFORE any Instance Actor is created,
// so the boot batch's EnsureDclConnections already resolves to the definition-owned
// config. This is the failover half of the row-89 fix: a singleton incarnation starting
// config. This is the failover half of the fix: a singleton incarnation starting
// on the peer node rebuilds the whole DCL from flattened snapshots, and without the
// overlay in place first it would rebuild them STALE and only correct itself a sweep
// tick later — a needless create-then-diverge on every failover.
@@ -764,7 +763,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (msg.EnabledConfigs.Count == 0)
{
// Nothing to create, so boot-time creation is trivially complete and the instance
// reconcile sweep may run. This is exactly the row-78 live case: the node booted
// reconcile sweep may run. This is exactly the live case: the node booted
// with an empty deployed_configurations table and CDC replication seeds it moments
// later, with no boot load left to notice.
_startupInstanceCreationComplete = true;
@@ -777,7 +776,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// WP3.1: pre-warms one staggered startup batch's script and trigger-expression compiles
/// Pre-warms one staggered startup batch's script and trigger-expression compiles
/// off the actor thread, then pipes <see cref="BatchCompileWarmed"/> so
/// <see cref="CreateInstanceActorBatch"/> can create the batch's Instance Actors with
/// every <c>PreStart</c> compile already a cache hit.
@@ -814,7 +813,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
catch
{
// Best-effort warm — a throw here just means that instance's PreStart
// compiles the hard way, exactly as it did before WP3.1.
// compiles the hard way, exactly as it did before the warm-then-gate change.
}
}
@@ -896,7 +895,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
// WP3.1 warm-then-gate. The S3 gate below must stay synchronous on the actor thread
// Warm-then-gate. The S3 gate below must stay synchronous on the actor thread
// (mailbox FIFO is what makes redeploy-supersede and delete-during-redeploy correct),
// but the Roslyn compile it performs used to hold the whole singleton — every OTHER
// instance's commands stalled behind one instance's compile. So the compile is WARMED
@@ -941,7 +940,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <see cref="DeployCompileWarmed"/> back so the authoritative gate can run on the actor
/// thread against a warm cache. The warm is best-effort: its verdict is discarded and any
/// exception swallowed, because <see cref="RunDeployGateAndProceed"/> re-derives the
/// verdict (and reproduces any failure) exactly as it did before WP3.1.
/// verdict (and reproduces any failure) exactly as it did before the warm-then-gate change.
/// </summary>
private void StartDeployCompileWarm(DeployInstanceCommand command)
{
@@ -1044,7 +1043,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// synchronously on the actor thread as a pure prefix step of the deploy
// application, so the existing redeploy-supersede / delete-during-redeploy
// ordering (which depends on strict mailbox FIFO) is preserved exactly.
// WP3.1 removed the COST rather than the ordering: the same compile has
// The warm-then-gate change removed the COST rather than the ordering: the same compile has
// already been warmed off-thread (StartDeployCompileWarm), so this call is
// pure cache hits and no longer holds the singleton — while the per-instance
// warm guard keeps this instance's own command ordering intact. Redeploys and
@@ -1345,7 +1344,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Static overrides and mirrored native alarm state are reset on redeployment per
// design decision — the new config may bind different sources, and the source snapshot
// re-seeds the mirror on (re)subscribe. Dispatched HERE, BEFORE the successor exists,
// and handed to it as a gate (arch-review 2026-08-19 P1-2). It used to run inside the
// and handed to it as a gate. It used to run inside the
// persistence task below, i.e. after the actor was already created AND queued behind a
// full config-JSON write, so the successor's PreStart override load routinely won the
// race and applied the PREDECESSOR's overrides — including keys absent from the new
@@ -1557,7 +1556,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = command.InstanceUniqueName;
// WP3.1 warm-then-gate: a deploy for this instance is mid-compile-warm; queue this
// Warm-then-gate: a deploy for this instance is mid-compile-warm; queue this
// command so same-instance ordering is preserved (see TryBufferDuringDeployWarm).
if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;
if (DeferLifecycleStop(instanceName, command.CommandId, false, replyTo)) return;
@@ -1567,7 +1566,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = replyTo;
// Row 101 (initiation half): the actor has just stopped the Instance Actor while the
// durable row still reads enabled, which is exactly the shape the row-78 sweep repairs.
// durable row still reads enabled, which is exactly the shape the reconcile sweep repairs.
// Any store read in flight would therefore "prove" a divergence that does not exist and
// restart the instance the operator just disabled.
BumpStoreWriteGeneration("disable dispatch");
@@ -1617,7 +1616,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
// WP3.1 warm-then-gate: see TryBufferDuringDeployWarm.
// Warm-then-gate: see TryBufferDuringDeployWarm.
if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;
var sender = replyTo;
@@ -1628,7 +1627,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Task.Run(async () =>
{
await _storage.SetInstanceEnabledAsync(instanceName, true);
// Row P1-4: a single-row read — this used to materialize every deployed config
// A single-row read — this used to materialize every deployed config
// (including every ConfigJson blob) just to FirstOrDefault this one instance.
var config = await _storage.GetDeployedConfigAsync(instanceName);
return new EnableResult(command, config, null, sender);
@@ -1722,7 +1721,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var generationAtRead = _storeWriteGeneration;
Task.Run(async () =>
{
// Register P1-4: read a single row through _deployedConfigLoader (defaults to
// Read a single row through _deployedConfigLoader (defaults to
// _storage.GetDeployedConfigAsync — a WHERE instance_unique_name = @name read, not
// the full-table-including-ConfigJson load the boot sequence uses), which still lets
// a test hold the read open and land the result behind a committed disable/delete.
@@ -1821,7 +1820,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <summary>
/// The one place an advisory self-heal turns a stored, enabled config into a running
/// Instance Actor — shared by the row-67 ensure-started handler and the row-78 sweep so
/// Instance Actor — shared by the ensure-started handler and the reconcile sweep so
/// both produce identical bookkeeping and identical operator-facing surfacing. Callers
/// are responsible for the guards; this method assumes they have passed.
/// </summary>
@@ -1915,7 +1914,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Row 101, sweep half: this node mutated one of the two tables while the read was in
// flight, so the snapshot predates state the actor has already advanced past. Discard it
// WHOLE — no overlay apply, no row-78 start, no row-72 restart. Every repair this sweep
// WHOLE — no overlay apply, no sweep start, no snapshot restart. Every repair this sweep
// makes is derived from "the store says X, the runtime says Y", and a snapshot older
// than the runtime inverts that comparison: it would restart an instance BACKWARD onto
// the revision a deploy just replaced, or resurrect one a disable/delete just stopped.
@@ -2099,7 +2098,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = command.InstanceUniqueName;
// WP3.1 warm-then-gate: see TryBufferDuringDeployWarm.
// Warm-then-gate: see TryBufferDuringDeployWarm.
if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;
if (DeferLifecycleStop(instanceName, command.CommandId, true, replyTo)) return;
@@ -2191,7 +2190,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
var sender = Sender;
var instanceName = request.InstanceUniqueName;
// Row P1-4: a single-row, ConfigJson-free projection — this used to materialize every
// A single-row, ConfigJson-free projection — this used to materialize every
// deployed config (including every ConfigJson blob) just to FirstOrDefault this one
// instance's DeploymentId/RevisionHash. Neither is ever read here.
_storage.GetDeployedConfigMetadataAsync(instanceName).ContinueWith(t =>
@@ -2493,13 +2492,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <item>DEFINITIONS half: absences "prove" just-applied names disappeared, tombstoning and
/// STOPPING live connections that nothing re-creates until an instance redeploy; rows push
/// superseded configs onto live connections and flip-flop with the next fresh read.</item>
/// <item>CONFIGS half, row-72 leg: a snapshot carrying the revision a deploy has just
/// <item>CONFIGS half, deploy-revision leg: a snapshot carrying the revision a deploy has just
/// replaced makes <see cref="TryRestartStaleInstance"/> "repair" the instance BACKWARD —
/// it redeploys the snapshot's own (old) config JSON through the redeploy path.
/// <see cref="InstanceIsOwnedByADeployPipeline"/> does not cover this: it only holds while
/// the deploy is in flight, and the stale snapshot can land after the pipeline released
/// ownership.</item>
/// <item>CONFIGS half, row-78 leg: a snapshot read before an undeploy/delete or a disable
/// <item>CONFIGS half, stale-snapshot leg: a snapshot read before an undeploy/delete or a disable
/// committed still shows an enabled row while the actor is already stopped, so
/// <see cref="StartStoredInstance"/> RESURRECTS a just-stopped instance.</item>
/// </list>
@@ -2543,7 +2542,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
/// <summary>
/// The row-101 freshness test both snapshot consumers run BEFORE touching anything the
/// The freshness test both snapshot consumers run BEFORE touching anything the
/// snapshot carries. <see langword="true"/> means this actor mutated one of the two tables
/// while the read was in flight, so the snapshot predates state the actor has already
/// advanced past and must be discarded whole (see <see cref="_storeWriteGeneration"/> for
@@ -3231,7 +3230,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Task.Run(async () =>
{
// M2 (final review): the DCL push for this deploy's data connections is captured
// The DCL push for this deploy's data connections is captured
// here and sent only AFTER the shipped secrets are written and mirrored (in the
// finally below), so a connection whose config references a secret this same deploy
// ships resolves it on its FIRST connect instead of failing "not found" and waiting
@@ -3279,7 +3278,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
await _storage.DeleteExternalSystemsExceptAsync(
command.ExternalSystems.Select(es => es.Name).ToList());
// WP2.6a: drop the shared read cache so the next script call sees
// Drop the shared read cache so the next script call sees
// the freshly-applied definitions/method lists instead of a stale
// snapshot loaded before this deploy.
_externalSystemCache?.InvalidateAll();
@@ -3335,7 +3334,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// artifact bundle. Reconciled removals ride the same
// message so the live DCL connection actor is stopped
// on the actor thread alongside the hash-cache eviction.
// Sent after the secrets step — see pendingDclApply above (M2).
// Sent after the secrets step — see pendingDclApply above.
pendingDclApply = new ApplyArtifactDataConnectionsToDcl(
command.DataConnections, removedConnections);
}
@@ -3343,7 +3342,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// SMTP configuration is
// central-only and is never stored on a site (see the purge above).
// Site-secrets artifact deploy (Task 10): write the shipped secrets to the
// Site-secrets artifact deploy: write the shipped secrets to the
// replicated deployed_secrets table, then reconcile THIS node's own mirror
// synchronously — the reply's SecretsApplied must reflect this node's own
// apply/verify outcome, not whatever the next periodic pass finds. A null
@@ -3465,7 +3464,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// </param>
/// <param name="staticStateResetGate">
/// The deploy path's static-override / native-alarm reset, dispatched BEFORE this call so the
/// new actor's PreStart override load can be ordered after it (arch-review 2026-08-19 P1-2).
/// new actor's PreStart override load can be ordered after it.
/// <see langword="null"/> on every non-deploy start (boot batch, enable, self-heal), where
/// nothing is being reset and the load runs immediately as before.
/// </param>
@@ -3479,13 +3478,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
//
// This used to be the caller's job, and only TWO of the four callers did it: the
// boot-time batch and ApplyDeployment. The two self-heal starts did not —
// StartStoredInstance (the shared row-67 ensure-started / row-78 sweep path) and
// StartStoredInstance (the shared ensure-started / reconcile sweep path) and
// HandleEnableResult. Both therefore created an instance whose every tag subscribe was
// answered "Unknown connection: X" by the DCL manager, forever, because nothing else on
// the node would ever create that connection actor:
//
// - Row 85's live symptom (2026-08-16): a replication-seeded deployed_configurations row
// is started by the row-78 sweep, but the connection actor is only ever created as a
// is started by the reconcile sweep, but the connection actor is only ever created as a
// side effect of a deploy or the boot load, so tag subscribes failed for 6+ minutes
// until a second pair restart let the boot load create it.
// - The same defect is reachable with no replication at all: the boot load reads only
@@ -3587,7 +3586,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
internal int InstanceActorCount => _instanceActors.Count;
/// <summary>
/// Updates the health collector with current instance counts, and (WP3.1) grows the
/// Updates the health collector with current instance counts, and grows the
/// script-execution pool to match.
/// Total deployed = _deployedInstanceNames.Count, enabled = running actors, disabled = difference.
/// </summary>
@@ -3598,7 +3597,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
enabled: _instanceActors.Count,
disabled: _deployedInstanceNames.Count - _instanceActors.Count);
// WP3.1: the blocking script pool scales with the number of running instances rather
// The blocking script pool scales with the number of running instances rather
// than sitting at a fixed 8 forever. This is the single call site because it already
// runs on every deploy / undeploy / enable / disable and once per staggered startup
// batch. Growth is idempotent and one-way — see ScriptExecutionScheduler.EnsureCapacity.
@@ -3672,7 +3671,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
List<DeployedInstance> EnabledConfigs, int CompiledCount, int TotalCount);
/// <summary>
/// WP3.1 warm-then-gate: piped back to self once a deployment's scripts have been
/// Warm-then-gate: piped back to self once a deployment's scripts have been
/// compiled off the actor thread into the process-wide compile cache. Carries the exact
/// command instance it warmed so a result superseded mid-warm is recognised by reference
/// and dropped.
@@ -3680,7 +3679,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
internal sealed record DeployCompileWarmed(DeployInstanceCommand Command);
/// <summary>
/// WP3.1: piped back to self once a staggered startup batch's compiles have been
/// Piped back to self once a staggered startup batch's compiles have been
/// pre-warmed, carrying the original batch message so actor creation resumes unchanged.
/// </summary>
internal sealed record BatchCompileWarmed(StartNextBatch Batch);
@@ -105,11 +105,11 @@ public class InstanceActor : ReceiveActor
// deserialized configuration (last-wins on duplicate canonical names,
// mirroring the rest of the actor's by-name dictionaries). Each entry
// additionally carries its DataType/ElementDataType parsed ONCE at
// config-apply time (perf remediation, arch-review WP1.5) so the hot
// config-apply time so the hot
// TagValueUpdate path never re-Enum.TryParse's a string per update.
private readonly Dictionary<string, AttributeIndexEntry> _resolvedAttributeByName = new();
// Static-override write coalescing (perf remediation, arch-review WP1.5):
// Static-override write coalescing:
// mirrors NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape.
// A burst of rapid SetAttribute calls on the same or different attributes
// collapses into one batched SQLite transaction on a short single-shot
@@ -124,7 +124,7 @@ public class InstanceActor : ReceiveActor
/// <summary>
/// The deploy path's static-override / native-alarm reset, supplied by
/// <see cref="DeploymentManagerActor.CreateInstanceActor"/> on a (re)deploy and
/// <see langword="null"/> on every other start (arch-review 2026-08-19 P1-2). When set,
/// <see langword="null"/> on every other start When set,
/// <see cref="PreStart"/> starts the override load only once it has completed, so a
/// redeploy can never load the PREDECESSOR's overrides into the successor's memory.
/// </summary>
@@ -135,8 +135,7 @@ public class InstanceActor : ReceiveActor
internal sealed record StaticOverridesFlushed(IActorRef Actor, Exception? Error);
/// <summary>
/// Cached string forms of <see cref="QualityCode"/> (perf remediation, arch-review
/// WP1.5) — the TagValueUpdate ingest path calls this once or twice per update, so a
/// Cached string forms of <see cref="QualityCode"/> — the TagValueUpdate ingest path calls this once or twice per update, so a
/// static lookup replaces repeated <see cref="Enum.ToString()"/> reflection.
/// </summary>
private static readonly Dictionary<QualityCode, string> QualityStrings = new()
@@ -150,7 +149,7 @@ public class InstanceActor : ReceiveActor
QualityStrings.TryGetValue(quality, out var s) ? s : quality.ToString();
/// <summary>
/// O(1) attribute-resolution index entry (perf remediation, arch-review WP1.5): wraps a
/// O(1) attribute-resolution index entry: wraps a
/// <see cref="ResolvedAttribute"/> together with its <see cref="DataType"/> /
/// element <see cref="DataType"/> / IsList classification, parsed ONCE when the entry
/// is built (constructor) instead of re-parsed via <see cref="Enum.TryParse{DataType}"/>
@@ -280,7 +279,7 @@ public class InstanceActor : ReceiveActor
{
// Parse DataType/ElementDataType ONCE here (config-apply time) and index
// the wrapping entry for O(1) lookup on the hot TagValueUpdate ingest path
// (last-wins on duplicate names) — perf remediation, arch-review WP1.5.
// (last-wins on duplicate names)
var dataType = ParseDataType(attr.DataType);
var elementDataType = ParseElementDataType(attr.ElementDataType);
var isList = dataType == DataType.List;
@@ -398,8 +397,7 @@ public class InstanceActor : ReceiveActor
// Asynchronously load static overrides from SQLite and pipe to self. On a (re)deploy
// the read is chained behind the reset the Deployment Manager dispatched before this
// actor existed, so it can only ever observe the CLEARED table (arch-review
// 2026-08-19 P1-2). A faulted reset does not block the load — the deploy itself fails
// actor existed, so it can only ever observe the CLEARED table. A faulted reset does not block the load — the deploy itself fails
// on that fault through the persistence join, which stops this actor anyway.
var self = Self;
var instanceName = _instanceUniqueName;
@@ -519,7 +517,7 @@ public class InstanceActor : ReceiveActor
private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
{
// O(1) lookup via the existing hot-path index instead of a linear scan over
// _configuration.Attributes (perf remediation, arch-review WP1.5).
// _configuration.Attributes.
_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved);
// Reject writes targeting an attribute that does not exist
@@ -640,7 +638,7 @@ public class InstanceActor : ReceiveActor
/// <summary>
/// Records the latest value per attribute name and arms the coalesced flush timer
/// (perf remediation, arch-review WP1.5) — mirrors NativeAlarmActor's
/// — mirrors NativeAlarmActor's
/// MarkDirtyUpsert/FlushDirtyUpserts (P4) shape. A burst of rapid static writes
/// collapses into one batched SQLite transaction instead of a write per call.
/// </summary>
@@ -1157,7 +1155,7 @@ public class InstanceActor : ReceiveActor
/// actor.
///
/// Takes the already-parsed <paramref name="dataType"/>/<paramref name="elementType"/>
/// (perf remediation, arch-review WP1.5) rather than re-parsing from the attribute's
/// rather than re-parsing from the attribute's
/// raw DataType strings — every call site now resolves these once via
/// <see cref="AttributeIndexEntry"/> at config-apply time.
/// </summary>
@@ -1185,8 +1183,7 @@ public class InstanceActor : ReceiveActor
/// non-enumerable value, or any element that cannot be coerced — the caller then
/// marks the attribute quality Bad. Never throws.
///
/// Takes the already-parsed <paramref name="elementType"/> (perf remediation,
/// arch-review WP1.5) — see <see cref="DecodeAttributeValue"/>.
/// Takes the already-parsed <paramref name="elementType"/> — see <see cref="DecodeAttributeValue"/>.
/// </summary>
private bool TryCoerceListValue(DataType? elementType, object? incoming, out object? typedList)
{
@@ -1325,13 +1322,18 @@ public class InstanceActor : ReceiveActor
}
/// <summary>
/// Pure backoff step for the tag-subscribe retry (Lever 1, 2026-08-28): double the
/// Pure backoff step for the tag-subscribe retry: double the
/// interval just used, apply ±20 % jitter from <paramref name="jitterUnit"/> (a
/// uniform sample in [0,1]; 0.5 = no jitter) so N instances on one connection do not
/// re-storm in lockstep, then clamp to [floor, ceiling]. A ceiling below the floor
/// degrades to a fixed floor interval rather than shrinking below it — same contract
/// as <c>DataConnectionActor.NextTagResolutionInterval</c>.
/// </summary>
/// <param name="current">The interval just used.</param>
/// <param name="floor">The minimum interval.</param>
/// <param name="max">The maximum interval; a value below <paramref name="floor"/> degrades to the floor.</param>
/// <param name="jitterUnit">A uniform sample in [0,1] used to apply jitter; 0.5 means no jitter.</param>
/// <returns>The next retry interval, clamped to the allowed range.</returns>
internal static TimeSpan NextTagSubscribeInterval(TimeSpan current, TimeSpan floor, TimeSpan max, double jitterUnit)
{
var ceiling = max < floor ? floor : max;
@@ -1658,7 +1660,7 @@ public class InstanceActor : ReceiveActor
{
var now = DateTimeOffset.UtcNow;
// Alarms-only projection (WP2.3): the central live-alarm cache seeds/reconciles
// Alarms-only projection: the central live-alarm cache seeds/reconciles
// through this same query surface and throws every attribute row away. Building
// and shipping them is pure waste — one instance with a few hundred attributes
// costs more on the wire than the whole alarm set of the site. The flag is
@@ -2153,7 +2155,7 @@ public class InstanceActor : ReceiveActor
/// <param name="SourceExecutionId">Execution id of the writer that took the arm.</param>
private sealed record DisarmTrace(string AttributeName, Guid SourceExecutionId);
/// <summary>Self-tell that fires the coalesced static-override persist flush (P4, arch-review WP1.5).</summary>
/// <summary>Self-tell that fires the coalesced static-override persist flush.</summary>
private sealed class FlushStaticOverrides
{
public static readonly FlushStaticOverrides Instance = new();
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
/// Holds compiled script delegate, manages trigger configuration, and launches script
/// runs per invocation. Does not block on run completion.
///
/// <para>WP3.1: runs are launched directly via <see cref="ScriptRunLauncher"/> rather than
/// <para>Runs are launched directly via <see cref="ScriptRunLauncher"/> rather than
/// through a short-lived <c>ScriptExecutionActor</c> child (which had no <c>Receive</c>
/// handler, no <c>PostStop</c>, and whose <c>IActorRef</c> was never a message target — pure
/// per-run actor-cell overhead). Concurrent runs are bounded by
@@ -51,19 +51,19 @@ public class ScriptActor : ReceiveActor, IWithTimers
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide
/// Script-execution scheduler seam: the process-wide
/// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so
/// this actor's launched script bodies run on a caller-owned pool instead of the
/// shared one. Resolved lazily at each use so the null (host) path stays
/// byte-for-byte identical to the previous static call.
///
/// <para>WP3.1: trigger-expression evaluation no longer uses this scheduler — see
/// <para>Trigger-expression evaluation no longer uses this scheduler — see
/// <see cref="_evalGate"/>.</para>
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
/// <summary>
/// WP3.1 (finding #4): the concurrency gate for trigger-expression evaluation, or null
/// The concurrency gate for trigger-expression evaluation, or null
/// for the process-wide <see cref="TriggerEvalGate.Shared"/>. Evaluations run as plain
/// async work on the shared .NET thread pool behind this gate, NOT on
/// <see cref="_scheduler"/> — that is what stops an Expression trigger from queueing
@@ -72,7 +72,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
private readonly TriggerEvalGate? _evalGate;
/// <summary>
/// WP3.1: runs launched but not yet completed (queued or executing) for this script.
/// Runs launched but not yet completed (queued or executing) for this script.
/// Incremented at launch, decremented on <see cref="ScriptExecutionCompleted"/>, which
/// every terminal path emits — including the launch-path catch, so the counter cannot
/// leak. Touched only on the actor thread.
@@ -80,7 +80,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
private int _runsInFlight;
/// <summary>
/// WP3.1: when the last shed Warning site event was emitted for this script. Sheds are
/// When the last shed Warning site event was emitted for this script. Sheds are
/// ALWAYS counted on the health collector; the site event is rate-limited to one per
/// script per minute so a hot trigger against a saturated cap cannot flood
/// <c>site_events</c>.
@@ -152,8 +152,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <param name="initialAttributes">Initial attribute snapshot used to seed expression trigger evaluation state.</param>
/// <param name="healthCollector">Optional health metrics collector.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution context services.</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
/// <param name="evalGate">Optional trigger-expression concurrency gate override (WP3.1); null uses the process-wide shared gate.</param>
/// <param name="scheduler">Optional script-execution scheduler override; null uses the process-wide shared scheduler.</param>
/// <param name="evalGate">Optional trigger-expression concurrency gate override; null uses the process-wide shared gate.</param>
public ScriptActor(
string scriptName,
string instanceName,
@@ -246,7 +246,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
_scriptName, _instanceName);
}
// WP3.1: the Stop-on-failure SupervisorStrategy override is gone with the per-run
// The Stop-on-failure SupervisorStrategy override is gone with the per-run
// ScriptExecutionActor child it supervised. This actor has no children left; a
// launch-path throw is caught in SpawnExecution (which replies to the Ask caller and
// releases the in-flight slot) so it can never escalate to InstanceActor, which
@@ -348,7 +348,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// pending, so a storm of changes collapses to the latest snapshot without
/// unbounded task fan-out.
///
/// <para>WP3.1 (finding #4) changed WHERE it runs and WHEN its clock starts. It used
/// <para>Trigger evaluation runs off the script pool and its clock starts at enqueue. It used
/// to run on the dedicated <see cref="ScriptExecutionScheduler"/> — the same bounded
/// pool as blocking script bodies — so N blocked scripts stalled every Expression
/// trigger on the node indefinitely. It now runs as plain async work on the shared
@@ -556,14 +556,14 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>; beyond that the newest
/// trigger is shed (see <see cref="ShedRun"/>).
///
/// <para>WP3.1: the run is launched directly on the script-execution scheduler via
/// <para>The run is launched directly on the script-execution scheduler via
/// <see cref="ScriptRunLauncher"/> — the actor's mailbox stays on the default dispatcher,
/// but the script body runs on the bounded set of dedicated threads, so blocking script
/// I/O is contained there and cannot starve the shared .NET thread pool. No per-run child
/// actor is created.</para>
///
/// <para>The cap governs NEW work only (<paramref name="callDepth"/> 0 — trigger-driven
/// runs and depth-0 Ask calls). A nested <c>CallScript</c> is exempt: the calling run is
/// runs and top-level Ask calls). A nested <c>CallScript</c> is exempt: the calling run is
/// itself still counted in <see cref="_runsInFlight"/> while it awaits its callee (the
/// slot is released only by <see cref="ScriptExecutionCompleted"/>, sent after the body
/// returns), and a script calling ITSELF routes back to this same actor — so counting the
@@ -625,7 +625,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
parentExecutionId,
// Per-script timeout override (null = use global).
_executionTimeoutSeconds,
// Scheduler seam (#18): thread this actor's scheduler override down so
// Scheduler seam: thread this actor's scheduler override down so
// launched script bodies share the same pool (null = process-wide shared).
_scheduler,
// Flow Debugger (design §5.2): trace flag + value-change trigger detail.
@@ -634,7 +634,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
catch (Exception ex)
{
// WP3.1 supervision parity: the only failure the removed per-run child could
// Supervision parity: the only failure the removed per-run child could
// surface was a constructor throw (e.g. queueing onto a disposed scheduler),
// which the old OneForOneStrategy logged and stopped — leaving an Ask caller to
// hang with no reply. Same log shape, same "coordinator unaffected" outcome, plus
@@ -652,11 +652,11 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
/// <summary>
/// WP3.1 shed policy: refuses the incoming run because
/// Shed policy: refuses the incoming run because
/// <see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/> runs are already in flight.
/// The four already queued/running are kept — they are closest to their own deadlines and
/// already charged against them — so nothing is ever reordered. A trigger-driven run is
/// simply not launched; a depth-0 Ask (an inbound-API route, or a <c>CallScript</c> from
/// simply not launched; a top-level Ask (an inbound-API route, or a <c>CallScript</c> from
/// an unrelated script's run) gets an explicit error so the caller fails fast rather than
/// hanging to its Ask timeout. Nested (<c>callDepth &gt; 0</c>) launches never reach here —
/// see <see cref="SpawnExecution"/>.
@@ -709,7 +709,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
private void HandleExecutionCompleted(ScriptExecutionCompleted msg)
{
// WP3.1: release the in-flight slot. Every terminal path emits exactly one of these
// Release the in-flight slot. Every terminal path emits exactly one of these
// (success / timeout / failure / launch failure), so the counter tracks reality.
if (_runsInFlight > 0) _runsInFlight--;
@@ -719,7 +719,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
/// <summary>
/// WP3.1: runs launched but not yet completed. Exposed for regression coverage of the
/// Runs launched but not yet completed. Exposed for regression coverage of the
/// shed cap — the counter is otherwise invisible from outside the actor.
/// </summary>
internal int RunsInFlight => _runsInFlight;
@@ -116,7 +116,7 @@ public class SiteStorageService
/// or <see langword="null"/> if no row exists for it.
/// </summary>
/// <remarks>
/// Register P1-4: <see cref="GetAllDeployedConfigsAsync"/> plus a <c>FirstOrDefault</c> was
/// <see cref="GetAllDeployedConfigsAsync"/> plus a <c>FirstOrDefault</c> was
/// materializing every deployed config — including every <c>ConfigJson</c> blob — just to find
/// one row. A volume-restore reconcile healing hundreds of instances turned that into hundreds
/// of full-table reads. Callers that need the full row (including <c>ConfigJson</c>) for exactly
@@ -162,7 +162,7 @@ public class SiteStorageService
/// <c>config_json</c> column, or <see langword="null"/> if no row exists for it.
/// </summary>
/// <remarks>
/// Register P1-4: for a caller that only needs staleness/identity metadata (e.g. the
/// For a caller that only needs staleness/identity metadata (e.g. the
/// central deployment-state query, which reports only <c>DeploymentId</c>/<c>RevisionHash</c>),
/// reading the <c>config_json</c> blob is pure waste. This projection is that lighter read.
/// </remarks>
@@ -432,8 +432,8 @@ public class SiteStorageService
}
/// <summary>
/// Batch counterpart of <see cref="SetStaticOverrideAsync"/> (perf remediation,
/// arch-review WP1.5): upserts a set of static attribute overrides for one instance
/// Batch counterpart of <see cref="SetStaticOverrideAsync"/> (perf remediation):
/// upserts a set of static attribute overrides for one instance
/// in a SINGLE connection + transaction with one prepared command re-bound per row.
/// Used by the InstanceActor's coalesced flush — mirrors NativeAlarmActor's
/// MarkDirtyUpsert/FlushDirtyUpserts (P4) pattern — so a burst of rapid SetAttribute
@@ -802,7 +802,7 @@ public class SiteStorageService
/// Returns every row in <c>deployed_secrets</c> like <see cref="GetDeployedSecretsAsync"/>,
/// but never throws for a single bad row: a row whose <c>row_json</c> does not deserialize
/// is returned in <c>Unreadable</c> (name, removal flag and the parse error — never the
/// row's content) instead of failing the whole read (final review M4, per-row isolation).
/// row's content) instead of failing the whole read (per-row isolation).
/// </summary>
/// <returns>The parsed rows and the rows that could not be parsed.</returns>
public async Task<(IReadOnlyList<DeployedSecretRow> Rows, IReadOnlyList<UnreadableDeployedSecretRow> Unreadable)>
@@ -925,7 +925,7 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
// ── Secret mirror status (review finding M1 — LocalDb-replicated, one row per node) ──
// ── Secret mirror status (LocalDb-replicated, one row per node) ──
/// <summary>
/// Records <paramref name="nodeName"/>'s current deployed-secrets mirror failures in the
@@ -1418,7 +1418,7 @@ public class DeployedInstance
/// Deployment-identity metadata for one deployed instance — the projection of
/// <see cref="DeployedInstance"/> that omits <c>ConfigJson</c>. Returned by
/// <see cref="SiteStorageService.GetDeployedConfigMetadataAsync"/> for callers that only need to
/// answer "is this instance deployed, and at what revision" (register P1-4).
/// answer "is this instance deployed, and at what revision".
/// </summary>
/// <param name="InstanceUniqueName">The unique name of the instance.</param>
/// <param name="DeploymentId">The unique deployment identifier.</param>
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
/// <summary>
/// WP2.6a (arch-review misc — site external-system resolution): the shared,
/// The shared,
/// redeploy-invalidated snapshot backing <see cref="SiteExternalSystemRepository"/>'s
/// reads.
/// <para>
@@ -62,6 +62,7 @@ public sealed class ExternalSystemDefinitionCache
/// </summary>
/// <param name="loader">Loads a fresh snapshot from storage; invoked at most once per miss.</param>
/// <param name="cancellationToken">Cancellation token for the load.</param>
/// <returns>The cached or freshly loaded snapshot.</returns>
internal async Task<Snapshot> GetOrLoadAsync(
Func<CancellationToken, Task<Snapshot>> loader, CancellationToken cancellationToken)
{
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
/// Write operations throw <see cref="NotSupportedException"/> because site-local
/// artifacts are managed exclusively through deployment from Central.
/// <para>
/// WP2.6a (arch-review misc): every <c>ExternalSystemDefinition</c>/<c>ExternalSystemMethod</c>
/// Every <c>ExternalSystemDefinition</c>/<c>ExternalSystemMethod</c>
/// read is served from an <see cref="ExternalSystemDefinitionCache"/> snapshot instead of
/// a fresh SQLite scan per call — the by-ID lookups in particular used to re-fetch every
/// row (and, for methods, re-parse every system's JSON method list) just to find one
@@ -63,6 +63,9 @@ public sealed class FilesAccessor
?? throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled,
"Script file access is not available on this node.");
/// <summary>Splits text into lines on CRLF, LF or CR boundaries.</summary>
/// <param name="text">The text to split.</param>
/// <returns>The lines of <paramref name="text"/>; empty when the text is empty.</returns>
internal static IReadOnlyList<string> SplitLines(string text)
{
if (text.Length == 0)
@@ -112,7 +112,7 @@ public class AttributeAccessor
}
/// <summary>
/// Reset-aware handshake (Gitea #36): writes <paramref name="resetKey"/>=<paramref name="resetValue"/>,
/// Reset-aware handshake: writes <paramref name="resetKey"/>=<paramref name="resetValue"/>,
/// waits (event-driven, bounded by <paramref name="resetTimeout"/>, default = <paramref name="timeout"/>)
/// for the device to ECHO that reset, and only then performs the ordinary
/// <see cref="WriteBatchAndWaitAsync(IReadOnlyDictionary{string, object?}, string, object?, string, object?, TimeSpan)"/>
@@ -36,7 +36,7 @@ public enum WorkerDetachOutcome
/// of dedicated threads, so script blocking is contained to those threads and cannot
/// exhaust the global pool.
///
/// <para>WP3.1 changed three things about that pool:</para>
/// <para>The pool differs from a fixed-size pool in three ways:</para>
/// <list type="number">
/// <item>It is no longer fixed-size. <see cref="EnsureCapacity"/> grows it towards
/// <see cref="ComputeTargetThreads"/> (instance-scaled, clamped between the configured
@@ -48,7 +48,7 @@ public enum WorkerDetachOutcome
/// <item>Trigger-expression evaluations no longer run here at all — they are non-blocking
/// by construction and now run on the shared thread pool behind
/// <see cref="TriggerEvalGate"/>, so an alarm's Expression trigger can never queue behind
/// eight blocking script bodies (finding #4).</item>
/// eight blocking script bodies.</item>
/// </list>
///
/// The scheduler is process-wide (one set of threads for all instances) and is sized
@@ -157,7 +157,7 @@ public sealed class ScriptLogger
try
{
// Final review M3: worded identically to the central InboundScriptLogger's closing row,
// Worded identically to the central InboundScriptLogger's closing row,
// so the two surfaces read the same in the step-log panel (only the option key differs).
_emit("warn", "runtime",
$"log line cap reached - {n} further log line(s) were not captured " +
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md):
/// Site-events volume policy (docs/plans/2026-08-15-site-events-policy-design.md):
/// decides whether an instance script run should emit the legacy per-run Started/Completed
/// Info site events, or stay silent and let <see cref="ScriptRunSummaryRecorder"/>'s interval
/// aggregate carry the signal instead. Pure and side-effect-free — no DI, no actor context —
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.1: launches one script or alarm on-trigger run directly from its owning coordinator
/// Launches one script or alarm on-trigger run directly from its owning coordinator
/// (<see cref="ScriptActor"/> / <see cref="AlarmActor"/>), replacing the short-lived
/// <c>ScriptExecutionActor</c> and <c>AlarmExecutionActor</c>.
///
@@ -76,7 +76,7 @@ internal static class ScriptRunLauncher
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
/// <param name="parentExecutionId">ExecutionId of the spawning execution for audit correlation; null for root runs.</param>
/// <param name="executionTimeoutSeconds">Per-script execution timeout in seconds. Null or non-positive falls back to the global value.</param>
/// <param name="scheduler">Script-execution scheduler seam (#18); null selects the process-wide shared instance.</param>
/// <param name="scheduler">Script-execution scheduler seam; null selects the process-wide shared instance.</param>
/// <param name="trace">Flow Debugger trace flag (design 2026-09-05 §5.2): when true the run emits <c>ScriptTrace</c> rows and returns them inline on the <see cref="ScriptCallResult"/>.</param>
/// <param name="triggerExtra">Flow Debugger: trigger detail for a traced value-change run (stamped into the <c>ScriptRun</c> row's <c>Extra</c>); null for calls.</param>
public static void LaunchScript(
@@ -146,13 +146,12 @@ internal static class ScriptRunLauncher
/// <param name="completionTarget">The owning <see cref="AlarmActor"/>, which receives the completion message.</param>
/// <param name="logger">Logger for execution diagnostics.</param>
/// <param name="healthCollector">
/// Optional health collector for recording execution metrics (arch-review 2026-08-19
/// P1-3): the alarm on-trigger run shares the script-error counter with
/// Optional health collector for recording execution metrics: the alarm on-trigger run shares the script-error counter with
/// <see cref="RunScriptAsync"/> so a wedged/failed alarm body is visible the same way a
/// wedged/failed instance script is.
/// </param>
/// <param name="serviceProvider">
/// Optional DI service provider (arch-review 2026-08-19 P1-3): resolves the singleton
/// Optional DI service provider: resolves the singleton
/// <see cref="ISiteEventLogger"/> (stuck-watchdog reporting) and
/// <see cref="ScriptRunSummaryRecorder"/> (aggregate run counters), exactly like the
/// instance-script path.
@@ -164,7 +163,7 @@ internal static class ScriptRunLauncher
/// write fired this alarm. Null when the firing value came from the Data Connection Layer
/// (external data has no spawning execution) — that on-trigger run is a tree ROOT.
/// </param>
/// <param name="scheduler">Script-execution scheduler seam (#18); null selects the process-wide shared instance.</param>
/// <param name="scheduler">Script-execution scheduler seam; null selects the process-wide shared instance.</param>
/// <param name="siteCommunicationActor">Site transport for notification status queries, resolved on the actor thread.</param>
public static void LaunchAlarmScript(
string alarmName,
@@ -268,7 +267,7 @@ internal static class ScriptRunLauncher
// it is available to the catch blocks regardless of scope state.
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
// WP3.2 (site_events volume policy): both are singletons, resolved from the root
// site_events volume policy: both are singletons, resolved from the root
// provider up front so they too are available to the catch blocks. optionsMonitor is
// null in tests that pass no serviceProvider (SingleServiceProvider et al.) — the
// constructor-passed `options` is the fallback, exactly like siteEventLogger's null
@@ -282,7 +281,7 @@ internal static class ScriptRunLauncher
var emitPerRunEvents = ScriptRunEventPolicy.ShouldEmitPerRun(effectiveOptions, instanceName, scriptName);
var runStopwatch = Stopwatch.StartNew();
// WP3.1: identify the worker this body is holding, so the watchdog can detach and
// Identify the worker this body is holding, so the watchdog can detach and
// replace it if the body wedges. Read in the FIRST synchronous segment — a script
// that later hops threads on an await no longer holds this worker, and the run-stamp
// guard in TryDetachWorker will (correctly) refuse to detach it.
@@ -299,7 +298,7 @@ internal static class ScriptRunLauncher
try
{
// WP3.1 shed-at-dequeue: the deadline was armed at ENQUEUE, so an already-cancelled
// Shed-at-dequeue: the deadline was armed at ENQUEUE, so an already-cancelled
// token here means this run spent its entire budget queueing. Running it now would
// burn a scarce script thread on work that is already stale — throw straight into
// the existing timeout path instead, BEFORE the "started" event and before the body.
@@ -421,7 +420,7 @@ internal static class ScriptRunLauncher
Scope = scope
};
// WP3.2: the aggregate ALWAYS records this run (that's the point of the interval
// The aggregate ALWAYS records this run (that's the point of the interval
// summary), but the per-run Info event itself is gated by ScriptRunEventPolicy —
// off by default, on globally via PerRunScriptEvents, or on for named scripts via
// PerRunScriptEventScripts. Fire-and-forget (the `_ =` discards the task) so the
@@ -478,7 +477,7 @@ internal static class ScriptRunLauncher
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' timed out after {timeout.TotalSeconds}s";
logger.LogWarning("{Message} (run {RunId})", errorMsg, runId);
// Failures recorded to site event log; script NOT disabled after failure. WP3.2:
// Failures recorded to site event log; script NOT disabled after failure.
// Error-level emissions are unconditional — never gated by ScriptRunEventPolicy.
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
@@ -504,7 +503,7 @@ internal static class ScriptRunLauncher
{
healthCollector?.IncrementScriptError();
summaryRecorder?.RecordFailed(instanceName, scriptName, runStopwatch.ElapsedMilliseconds);
// Failures recorded to site event log; script NOT disabled after failure. WP3.2:
// Failures recorded to site event log; script NOT disabled after failure.
// Error-level emissions are unconditional — never gated by ScriptRunEventPolicy.
var errorMsg = $"Script '{scriptName}' on instance '{instanceName}' failed: {ex.Message}";
logger.LogError(ex, "Script execution failed: {Script} on {Instance} (run {RunId})",
@@ -556,6 +555,13 @@ internal static class ScriptRunLauncher
/// the ACTOR thread, outside any launch scope. <c>elapsedMs</c> is 0 — the run never ran.
/// Best-effort and never throws: a no-op when there is no provider or no writer.</para>
/// </summary>
/// <param name="serviceProvider">Root service provider used to resolve the audit writer and identity providers; no-op when <c>null</c>.</param>
/// <param name="logger">Logger for best-effort failure reporting.</param>
/// <param name="scriptName">Name of the script whose run was refused.</param>
/// <param name="instanceName">Name of the instance owning the script.</param>
/// <param name="parentExecutionId">ExecutionId of the spawning execution; null for root runs.</param>
/// <param name="reason">Human-readable guard reason the run was refused.</param>
/// <param name="triggerExtra">Trigger detail stamped into the row's <c>Extra</c> for a traced value-change run; null otherwise.</param>
internal static void EmitSuppressedRun(
IServiceProvider? serviceProvider,
ILogger logger,
@@ -668,7 +674,7 @@ internal static class ScriptRunLauncher
CancellationTokenSource cts,
ICanTell? siteCommunicationActor)
{
// arch-review 2026-08-19 P1-3: singletons resolved from the root provider up front,
// Singletons resolved from the root provider up front,
// exactly like RunScriptAsync, so they are available to the catch blocks regardless
// of scope state. Null in tests that pass no serviceProvider — degrades the same way
// the instance-script path does (no site event log / no aggregate counters).
@@ -681,7 +687,7 @@ internal static class ScriptRunLauncher
var scriptName = $"AlarmActor:{alarmName}";
var runStopwatch = Stopwatch.StartNew();
// arch-review 2026-08-19 P1-3: identify the worker this body is holding, so the
// Identify the worker this body is holding, so the
// stuck-script watchdog can detach and replace it if the body wedges — same
// mechanism, same gauge, as the instance-script path. Read in the FIRST synchronous
// segment, before any await.
@@ -716,7 +722,7 @@ internal static class ScriptRunLauncher
timeout,
instanceName,
logger,
// Final review M6: name the source so ScriptRuntimeContext's `_logTarget` derives
// Name the source so ScriptRuntimeContext's `_logTarget` derives
// the alarm this on-trigger run belongs to ("AlarmActor:Foo" -> "Foo") — without
// it every Log.* line from an alarm script was attributed to the INSTANCE, not the
// alarm. `scriptName` above is already the "AlarmActor:{alarmName}" source key, so
@@ -795,7 +801,7 @@ internal static class ScriptRunLauncher
}
/// <summary>
/// Arms the stuck-script watchdog (S2, extended by WP3.1).
/// Arms the stuck-script watchdog, which can also detach and replace a wedged worker.
///
/// <para>The CTS firing only REQUESTS cooperative cancellation; it does NOT free a thread
/// blocked in synchronous I/O. When the deadline elapses, this waits
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md):
/// Site-events volume policy (docs/plans/2026-08-15-site-events-policy-design.md):
/// site-only hosted service that periodically flushes <see cref="ScriptRunSummaryRecorder"/>
/// into one aggregate "script" Info site event. Registered by
/// <c>ServiceCollectionExtensions.AddSiteRuntime</c>, which only the site composition root
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md):
/// Site_events volume policy (docs/plans/2026-08-15-site-events-policy-design.md):
/// accumulates per-(instance, script) run counters between flushes and emits ONE aggregate
/// "script" Info site event per interval instead of the two per-run Started/Completed rows
/// that were the dominant <c>site_events</c> writer. Registered as a process-wide singleton
@@ -54,6 +54,8 @@ public sealed class ScriptRunSummaryRecorder
Volatile.Read(ref _counters).GetOrAdd((instanceName, scriptName), static _ => new Counters());
/// <summary>Records that a script run began executing (past the shed-at-dequeue check).</summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that ran.</param>
public void RecordStarted(string instanceName, string scriptName)
{
var counters = GetOrAddCounters(instanceName, scriptName);
@@ -61,6 +63,9 @@ public sealed class ScriptRunSummaryRecorder
}
/// <summary>Records that a script run completed successfully.</summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that ran.</param>
/// <param name="durationMs">The run duration in milliseconds.</param>
public void RecordCompleted(string instanceName, string scriptName, long durationMs)
{
var counters = GetOrAddCounters(instanceName, scriptName);
@@ -69,6 +74,9 @@ public sealed class ScriptRunSummaryRecorder
}
/// <summary>Records that a script run threw an unhandled exception.</summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that ran.</param>
/// <param name="durationMs">The run duration in milliseconds.</param>
public void RecordFailed(string instanceName, string scriptName, long durationMs)
{
var counters = GetOrAddCounters(instanceName, scriptName);
@@ -80,6 +88,9 @@ public sealed class ScriptRunSummaryRecorder
/// Records that a script run timed out — including a run shed at dequeue (its deadline
/// was already spent queueing, so it never reached <see cref="RecordStarted"/>).
/// </summary>
/// <param name="instanceName">The instance the script belongs to.</param>
/// <param name="scriptName">The script that timed out.</param>
/// <param name="durationMs">The run duration in milliseconds.</param>
public void RecordTimedOut(string instanceName, string scriptName, long durationMs)
{
var counters = GetOrAddCounters(instanceName, scriptName);
@@ -202,7 +213,7 @@ public sealed class ScriptRunSummaryRecorder
return true;
}
// Built once per assembly (WP1.5 convention) rather than per flush.
// Built once per assembly rather than per flush.
private static readonly JsonSerializerOptions DetailsJsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
@@ -310,6 +310,7 @@ public class ScriptRuntimeContext
/// <see cref="CreateChildContextForSharedScript"/>, so an inline shared script shares its
/// caller's line cap and anchor. Null (the default) creates this run's own logger.
/// </param>
/// <param name="fileReader">Host-mediated reader backing the script <c>Files</c> global; null disables file access.</param>
public ScriptRuntimeContext(
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
@@ -422,6 +423,9 @@ public class ScriptRuntimeContext
/// Wraps a trace sink so a throwing observer can neither fail the script call nor skip the
/// durable audit write that follows it. Null in, null out — an untraced run allocates nothing.
/// </summary>
/// <param name="sink">The trace sink to wrap, or null for an untraced run.</param>
/// <param name="logger">Logger used to report a rejecting sink.</param>
/// <returns>The guarded sink, or null when <paramref name="sink"/> is null.</returns>
internal static Action<AuditEvent>? GuardTraceSink(Action<AuditEvent>? sink, ILogger logger)
{
if (sink is null) return null;
@@ -973,7 +977,7 @@ public class ScriptRuntimeContext
.ConfigureAwait(false)).Matched;
/// <summary>
/// Reset-aware handshake (Gitea #36, design 2026-09-10 §5) backing the nine-argument
/// Reset-aware handshake (design 2026-09-10 §5) backing the nine-argument
/// <c>Attributes.WriteBatchAndWaitAsync</c> overload. Legs, in a FIXED order:
/// (1) write <paramref name="resetAttr"/>=<paramref name="resetEncoded"/> through
/// <see cref="SetAttribute"/> (throws on a device-write failure, like the batch leg);
@@ -986,7 +990,7 @@ public class ScriptRuntimeContext
/// Why the reset must be OBSERVED before the trigger is written: a subscription reports a
/// change from the last reported value, so a device that flips reset→done inside one
/// sampling interval never reports the reset — the cached previous-run "done" value would
/// then satisfy the response wait instantly (the #36 race), and an edge-triggered wait
/// then satisfy the response wait instantly (the stale-echo race), and an edge-triggered wait
/// would never resolve at all. Holding the trigger until the echo lands makes the existing
/// level-triggered fast-path correct again. Each leg is already a traced Flow Debugger
/// step, so a traced run shows all four.
@@ -1006,7 +1010,7 @@ public class ScriptRuntimeContext
/// <param name="resetAttr">Scope-resolved attribute written FIRST (usually the response attribute).</param>
/// <param name="resetEncoded">
/// Codec-encoded reset value the device must echo before anything else is written. NON-NULL by
/// contract: a null would write an empty string on leg 1 and then give the leg-2 waiter a null
/// contract: a null would write an empty string on leg 1 and then give the second-leg waiter a null
/// target — "any change" — which fast-paths on the stale cached value and reinstates the very
/// race this overload exists to close. The accessor rejects a null reset value before it gets here.
/// </param>
@@ -25,7 +25,7 @@ public sealed class ScriptSchedulerStatsReporter : BackgroundService
private readonly TimeSpan _pollInterval;
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide scheduler when null, or
/// Script-execution scheduler seam: the process-wide scheduler when null, or
/// an injected instance so the reporter samples the same pool the actors run on.
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
@@ -35,7 +35,7 @@ public sealed class ScriptSchedulerStatsReporter : BackgroundService
/// <param name="options">Site runtime options supplying the shared scheduler's thread count.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="pollInterval">Poll interval override; defaults to <see cref="DefaultPollInterval"/> (10 s).</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null samples the process-wide shared scheduler.</param>
/// <param name="scheduler">Optional script-execution scheduler override; null samples the process-wide shared scheduler.</param>
public ScriptSchedulerStatsReporter(
ISiteHealthCollector collector,
SiteRuntimeOptions options,
@@ -81,7 +81,7 @@ public sealed class ScriptSchedulerStatsReporter : BackgroundService
scheduler.QueueDepth,
scheduler.BusyThreadCount,
scheduler.OldestBusyAge?.TotalSeconds,
// WP3.1: workers detached and replaced by the stuck-script watchdog.
// Workers detached and replaced by the stuck-script watchdog.
scheduler.DetachedThreadCount);
}
catch (Exception ex)
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// 4096 because entries pin compiled assemblies, not just verdict strings.
/// </para>
///
/// <para><b>WP3.1 — approximate LRU replaced the overflow cliff.</b> The cache used to
/// <para><b>Approximate LRU replaced the overflow cliff.</b> The cache used to
/// clear itself wholesale on overflow. Instance scripts AND trigger expressions share this
/// cache, so on a site large enough to cross 1024 entries every overflow discarded up to 1023
/// live compiled scripts and the next deploy or Instance-Actor start paid a full recompile
@@ -41,7 +41,10 @@ public sealed class TraceCapturingLogger : ILogger
_sink = sink;
}
/// <inheritdoc />
/// <summary>Begins a logical scope on the wrapped logger.</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state.</param>
/// <returns>A disposable that ends the scope, or null if the inner logger returns none.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => _inner.BeginScope(state);
/// <summary>
@@ -52,7 +55,13 @@ public sealed class TraceCapturingLogger : ILogger
/// <returns><see langword="true"/> when the entry should be produced.</returns>
public bool IsEnabled(LogLevel level) => level >= LogLevel.Information || _inner.IsEnabled(level);
/// <inheritdoc />
/// <summary>Forwards the entry to the wrapped logger and captures it into the trace sink.</summary>
/// <typeparam name="TState">The type of the entry state.</typeparam>
/// <param name="level">The entry's log level.</param>
/// <param name="eventId">The entry's event id.</param>
/// <param name="state">The entry state.</param>
/// <param name="exception">The associated exception, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
// Forward first, and only if the inner logger actually wants it: IsEnabled above lies
@@ -38,7 +38,7 @@ public static class TraceStepScope
/// <summary>Restores the ambient step id captured at <see cref="Enter"/> time.</summary>
private sealed class Restore(Guid? prev) : IDisposable
{
/// <inheritdoc />
/// <summary>Restores the previous ambient step id.</summary>
public void Dispose() => _current.Value = prev;
}
}
@@ -1,9 +1,9 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// WP3.1 (finding #4): the process-wide concurrency gate for trigger-expression evaluation.
/// The process-wide concurrency gate for trigger-expression evaluation.
///
/// <para>Before WP3.1, <c>ScriptActor</c> and <c>AlarmActor</c> evaluated their compiled
/// <para>Previously, <c>ScriptActor</c> and <c>AlarmActor</c> evaluated their compiled
/// Expression triggers on the <see cref="ScriptExecutionScheduler"/> — the same bounded pool
/// of dedicated threads that runs script bodies. Eight scripts blocked in synchronous I/O
/// therefore stalled EVERY Expression trigger on the node behind them, for an unbounded time,
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets;
/// <param name="FailedNames">
/// The secret name behind each <paramref name="Failed"/> entry, in the SAME order (index-for-
/// index — each pass reports at most one failure per row, so the two lists always stay in
/// lockstep). Added for <c>DeploymentManagerActor.HandleDeployArtifacts</c> (Task 10): this
/// lockstep). Added for <c>DeploymentManagerActor.HandleDeployArtifacts</c>: this
/// mirror reconciles the WHOLE site-wide <c>deployed_secrets</c> table every pass, not only the
/// names a particular deploy just shipped, so a caller that needs to know whether a failure
/// belongs to ITS OWN shipped names — as opposed to some other, previously-deployed secret, or a
@@ -54,7 +54,7 @@ public sealed record MirrorResult(
/// master key.
/// </para>
/// <para>
/// <b>Apply semantics and the confirm-then-ledger fix (fix round 1, review Important #1).</b>
/// <b>Apply semantics and the confirm-then-ledger fix.</b>
/// <see cref="ISecretStore.ApplyReplicatedAsync"/> applies last-writer-wins strictly on
/// (<c>UpdatedUtc</c>, <c>Revision</c>) — see
/// <see cref="ZB.MOM.WW.Secrets.Abstractions.SecretLastWriterWins"/> — and a row that is not
@@ -106,7 +106,7 @@ public sealed record MirrorResult(
/// a later pass's "is this ledgered at the shipped revision" check stays correct.
/// </para>
/// <para>
/// <b>Per-row isolation (review Important #2).</b> Each row's apply/verify body runs in its own
/// <b>Per-row isolation.</b> Each row's apply/verify body runs in its own
/// try/catch; an exception — an unrecognised <c>ContentType</c> from a newer central, a
/// per-row store fault, a malformed name — is caught, reported as a <see cref="MirrorResult.Failed"/>
/// entry naming the secret and the exception type/message (never a value), logged with the same
@@ -114,7 +114,7 @@ public sealed record MirrorResult(
/// caller's own cancellation (<c>ct.IsCancellationRequested</c>) propagates.
/// </para>
/// <para>
/// <b>Cache invalidation (review Important #3).</b> The concrete resolver
/// <b>Cache invalidation.</b> The concrete resolver
/// (<c>DefaultSecretResolver</c>) caches decrypted plaintext for a TTL and also implements
/// <see cref="ISecretCacheInvalidator"/>; every ZB.MOM.WW.Secrets write path invalidates after a
/// write for exactly this reason. This mirror does the same: after every successful apply
@@ -124,10 +124,10 @@ public sealed record MirrorResult(
/// can observe a stale pre-rotation plaintext for up to the cache TTL.
/// </para>
/// <para>
/// <b>Node-local store assumption (review Minor #5; Task 11 removed every replication mode).</b>
/// <b>Node-local store assumption (every replication mode has been removed).</b>
/// This mirror assumes the site's <see cref="ISecretStore"/> is always plain, node-local, local
/// SQLite — the ONLY shape a Site node's store can be after Task 11 retired both the pull-only
/// gRPC secrets hub and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it (see
/// SQLite — the ONLY shape a Site node's store can be now that both the pull-only
/// gRPC secrets hub and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it are retired (see
/// <c>SecretsRegistration</c>). It would NOT have been safe to run this mirror against a store
/// decorated by either retired replication mode: <c>DeleteAsync</c> would have published the
/// tombstone to every other node sharing the decorator, so a site merely un-referencing a secret
@@ -156,7 +156,7 @@ public sealed record MirrorResult(
/// is in-memory only (per node process), which matches the ledger's own node-local scope.
/// </para>
/// <para>
/// <b>Status row for central (review finding M1).</b> A failure on the STANDBY node used to be
/// <b>Status row for central.</b> A failure on the STANDBY node used to be
/// visible only in that node's own log — site health reports are sent by the active node only.
/// After each pass this mirror records its node's current failure set (secret name + message,
/// never a value) in the LocalDb-replicated <c>secret_mirror_status</c> table, keyed by node
@@ -190,7 +190,7 @@ public sealed class DeployedSecretsMirror : BackgroundService
/// <summary>
/// The failure set (sorted name+message pairs) this process last wrote successfully to
/// <c>secret_mirror_status</c>, or <see langword="null"/> before the first successful write
/// — which is what forces the once-per-start-up write (review finding M1).
/// — which is what forces the once-per-start-up write.
/// </summary>
private IReadOnlyList<SecretMirrorFailure>? _lastWrittenStatus;
@@ -273,7 +273,7 @@ public sealed class DeployedSecretsMirror : BackgroundService
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Tolerant read (final review M4): a row whose row_json cannot be parsed is handed
// Tolerant read: a row whose row_json cannot be parsed is handed
// back separately instead of throwing out of the whole pass, so it is reported
// against its own name like any other per-row failure.
var (rows, unreadable) = await _storage.GetDeployedSecretsTolerantAsync().ConfigureAwait(false);
@@ -372,7 +372,7 @@ public sealed class DeployedSecretsMirror : BackgroundService
/// or the stored row no longer holds what this process wrote (deleted or overwritten by a
/// replicated change).
/// Best-effort: never throws, so the pass result is unaffected by a status-write fault.
/// See the class remarks (review finding M1).
/// See the class remarks.
/// </summary>
private async Task RecordStatusIfChangedAsync(IReadOnlyList<string> failedNames, IReadOnlyList<string> failed)
{
@@ -672,7 +672,7 @@ public sealed class DeployedSecretsMirror : BackgroundService
{
// Covers every other failure, including an OperationCanceledException NOT
// attributable to this service's own shutdown token (e.g. a store/command
// timeout) — that must not silently end the loop (review Minor #4).
// timeout) — that must not silently end the loop.
_logger.LogWarning(ex, "Deployed secrets mirror pass failed; retrying next interval.");
}
} while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets;
/// <summary>
/// Review finding M1: site-side hosted service that periodically reads EVERY node's row from
/// Site-side hosted service that periodically reads EVERY node's row from
/// the LocalDb-replicated <c>secret_mirror_status</c> table and pushes the list into
/// <see cref="ISiteHealthCollector.SetSecretMirrorStatuses"/>, so the next health report
/// carries each site node's deployed-secrets mirror failures — the standby's included.
@@ -40,7 +40,7 @@ public static class ServiceCollectionExtensions
// SiteScriptCompileCache, so "one Roslyn compile per script body per process"
// (N4) is exactly the statement that there is one of these per host. Downgrading
// it to Scoped/Transient would silently give every consumer its own cache and
// reintroduce the recompile storms WP3.1 removed — no test would fail on
// reintroduce the recompile storms that were removed — no test would fail on
// behaviour, only the registration pin in SiteRuntimeServiceRegistrationTests.
services.AddSingleton<ScriptCompilationService>();
@@ -81,7 +81,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<Scripts.IScriptFileReader, Scripts.ScriptFileReader>();
// Site-local repository implementations backed by SQLite.
// WP2.6a: ExternalSystemDefinitionCache is a singleton shared by every scoped
// ExternalSystemDefinitionCache is a singleton shared by every scoped
// SiteExternalSystemRepository instance (one per script-execution DI scope), so
// resolving the repository repeatedly does not each pay a fresh SQLite
// fetch-all — DeploymentManagerActor invalidates the shared cache after an
@@ -106,14 +106,14 @@ public static class ServiceCollectionExtensions
sp.GetRequiredService<IOptions<SiteRuntimeOptions>>().Value,
sp.GetRequiredService<ILogger<ScriptSchedulerStatsReporter>>()));
// WP2.6d: periodically lift SiteStreamManager's alarm-publish-queue drop count
// Periodically lift SiteStreamManager's alarm-publish-queue drop count
// onto the site health report.
services.AddHostedService(sp => new Streaming.SiteStreamAlarmDropReporter(
sp.GetRequiredService<HealthMonitoring.ISiteHealthCollector>(),
sp.GetRequiredService<SiteStreamManager>(),
sp.GetRequiredService<ILogger<Streaming.SiteStreamAlarmDropReporter>>()));
// WP3.2 (site_events volume policy): the recorder is a singleton so ScriptRunLauncher
// Site-events volume policy: the recorder is a singleton so ScriptRunLauncher
// can resolve it from the root service provider on every run, the same way it already
// resolves ISiteEventLogger. The flush service is registered ONLY here — central
// composition roots never call AddSiteRuntime(), which is what makes the periodic
@@ -125,7 +125,7 @@ public static class ServiceCollectionExtensions
sp.GetRequiredService<IOptionsMonitor<SiteRuntimeOptions>>(),
sp.GetRequiredService<ILogger<ScriptRunSummaryFlushService>>()));
// Site-secrets artifact deploy (Task 9): per-node reconcile of the site-wide,
// Site-secrets artifact deploy: per-node reconcile of the site-wide,
// LocalDb-replicated deployed_secrets table into THIS node's own ZB.MOM.WW.Secrets
// store. Runs on every site node — not a singleton, since mirrored_secrets (which
// names this node has already applied) is deliberately node-local, not replicated.
@@ -140,7 +140,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<DeployedSecretsMirror>();
services.AddHostedService(sp => sp.GetRequiredService<DeployedSecretsMirror>());
// Review finding M1: forward every node's replicated secret_mirror_status row onto the
// Forward every node's replicated secret_mirror_status row onto the
// site health report, so the ACTIVE node's report carries the STANDBY's mirror failures.
services.AddHostedService(sp => new SecretMirrorStatusReporter(
sp.GetRequiredService<HealthMonitoring.ISiteHealthCollector>(),
@@ -42,7 +42,7 @@ public class SiteRuntimeOptions
/// 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.
///
/// <para>WP3.1: this was a fixed size and is now the lower bound of a grow-only,
/// <para>This was a fixed size and is now the lower bound of a grow-only,
/// instance-scaled pool — see
/// <see cref="Scripts.ScriptExecutionScheduler.ComputeTargetThreads"/>. Existing
/// configurations keep exactly the previous behaviour at or below
@@ -53,7 +53,7 @@ public class SiteRuntimeOptions
public int ScriptExecutionThreadCount { get; set; } = 8;
/// <summary>
/// WP3.1: CEILING for the instance-scaled script-execution pool. The pool grows
/// CEILING for the instance-scaled script-execution pool. The pool grows
/// towards <c>ceil(enabledInstances / 8)</c> threads but never past this value; beyond
/// it, <see cref="MaxConcurrentRunsPerScript"/> is the real regulator. A 1 MB-stack
/// dedicated thread is cheap, so 32 (≈ 256 instances at the /8 ratio) is a generous
@@ -63,7 +63,7 @@ public class SiteRuntimeOptions
public int ScriptExecutionMaxThreadCount { get; set; } = 32;
/// <summary>
/// WP3.1 (finding #4): maximum number of trigger-expression evaluations allowed to run
/// Maximum number of trigger-expression evaluations allowed to run
/// concurrently across the whole process. Trigger expressions are non-blocking by
/// construction (<see cref="Scripts.TriggerExpressionGlobals"/> exposes only reads over an
/// in-memory snapshot, and the script trust gate has already denied I/O, network, and
@@ -75,7 +75,7 @@ public class SiteRuntimeOptions
public int TriggerEvalMaxConcurrency { get; set; } = Math.Max(2, Environment.ProcessorCount);
/// <summary>
/// WP3.1: timeout (seconds) for a single trigger-expression evaluation. Previously
/// Timeout (seconds) for a single trigger-expression evaluation. Previously
/// hardcoded at 2 s in both <c>ScriptActor</c> and <c>AlarmActor</c>. The deadline is now
/// armed when the evaluation is ENQUEUED, not when it is dequeued, so time spent waiting
/// on <see cref="TriggerEvalMaxConcurrency"/> burns the same budget — a saturated gate
@@ -85,7 +85,7 @@ public class SiteRuntimeOptions
public int TriggerEvalTimeoutSeconds { get; set; } = 2;
/// <summary>
/// WP3.1: maximum number of concurrent runs (queued or executing) for any single script
/// Maximum number of concurrent runs (queued or executing) for any single script
/// or alarm on-trigger script. A trigger that arrives while the cap is reached is SHED —
/// the newest run is refused, the four already in flight (which are closest to their own
/// deadlines) are kept, and no reordering occurs. A shed increments
@@ -125,7 +125,7 @@ public class SiteRuntimeOptions
/// <summary>
/// Ceiling (ms) of the tag-subscribe retry backoff. Must be >=
/// <see cref="TagSubscribeRetryIntervalMs"/>; equal makes the interval fixed
/// (pre-Lever-1 behaviour). Default: 60000ms.
/// (the original fixed-interval behaviour). Default: 60000ms.
/// </summary>
public int TagSubscribeRetryMaxIntervalMs { get; set; } = 60000;
@@ -160,7 +160,7 @@ public class SiteRuntimeOptions
public int StuckScriptGraceMs { get; set; } = 30000;
/// <summary>
/// WP2.6d (arch-review misc — site stream alarm-vs-attribute shared buffer):
/// Site stream alarm-vs-attribute shared buffer:
/// capacity of the bounded hand-off queue feeding <see cref="Streaming.SiteStreamManager"/>'s
/// dedicated alarm publish path. Alarm state changes are staged through this
/// queue into their OWN broadcast source — separate from the (far higher-volume)
@@ -173,7 +173,7 @@ public class SiteRuntimeOptions
public int AlarmPublishQueueCapacity { get; set; } = 2000;
/// <summary>
/// WP3.2 (site_events volume policy — docs/plans/2026-08-15-site-events-policy-design.md):
/// Site_events volume policy (docs/plans/2026-08-15-site-events-policy-design.md):
/// global switch restoring the legacy per-run Started/Completed Info site events for
/// EVERY instance script run. Off by default: those two rows per run were the dominant
/// <c>site_events</c> writer (~12 physical row-writes across a replicated pair per run,
@@ -188,7 +188,7 @@ public class SiteRuntimeOptions
public bool PerRunScriptEvents { get; set; }
/// <summary>
/// WP3.2: per-script opt-in restoring the legacy per-run Started/Completed Info site
/// Per-script opt-in restoring the legacy per-run Started/Completed Info site
/// events for NAMED scripts only, for live debugging without flipping the global
/// <see cref="PerRunScriptEvents"/> switch (and therefore without paying its volume
/// cost for every other script). Effective only while the global switch is off.
@@ -201,9 +201,9 @@ public class SiteRuntimeOptions
public List<string> PerRunScriptEventScripts { get; set; } = new();
/// <summary>
/// WP3.2: flush interval (seconds) for <see cref="Scripts.ScriptRunSummaryRecorder"/>'s
/// Flush interval (seconds) for <see cref="Scripts.ScriptRunSummaryRecorder"/>'s
/// interval aggregate — one "script" Info site event per interval summarizing every
/// instance script run since the last flush (headline totals + a top-50-scripts
/// instance script run since the last flush (headline totals + a top 50 scripts
/// breakdown, capped so a pathological deployment cannot mint an oversized row). An
/// interval with zero activity emits no row. <c>0</c> disables the summary flush
/// entirely (the recorder still accumulates counters; they are just never emitted or
@@ -229,7 +229,7 @@ public class SiteRuntimeOptions
/// central believes it replaced).</item>
/// </list>
/// The sweep is silent when there is nothing to repair. <c>0</c> disables it entirely
/// (the row-67 <c>EnsureInstanceStartedCommand</c> path is unaffected). Default: 60.
/// (the <c>EnsureInstanceStartedCommand</c> path is unaffected). Default: 60.
/// </summary>
public int InstanceSweepIntervalSeconds { get; set; } = 60;
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
/// <summary>
/// WP2.6d (arch-review misc — site stream alarm-vs-attribute shared buffer): site-side
/// Site-side
/// hosted service that periodically reads <see cref="SiteStreamManager.AlarmPublishDroppedCount"/>
/// and pushes it into <see cref="ISiteHealthCollector.SetSiteStreamAlarmDropCount"/> so
/// the next <see cref="ISiteHealthCollector.CollectReport"/> carries a fresh snapshot on
@@ -20,8 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
/// to instance events without referencing SiteRuntime directly.
/// </summary>
/// <remarks>
/// WP2.6d (arch-review misc — site stream alarm-vs-attribute shared buffer): attribute
/// and alarm events used to share ONE upstream <c>Source.ActorRef</c> + BroadcastHub
/// Attribute and alarm events used to share ONE upstream <c>Source.ActorRef</c> + BroadcastHub
/// pair, so a burst of attribute changes could fill that shared buffer and evict a
/// pending alarm transition before any subscriber ever saw it. Attribute and alarm
/// events now travel through entirely SEPARATE publish sources/hubs
@@ -48,7 +47,7 @@ public class SiteStreamManager : ISiteStreamSubscriber
// Attribute-only publish source/hub. By far the higher-volume of the two —
// deliberately kept on Source.ActorRef's built-in bounded-mailbox + DropHead
// behavior (unchanged from before WP2.6d); losing an occasional attribute update
// behavior (unchanged from before the alarm hub split); losing an occasional attribute update
// under a storm is the accepted, pre-existing trade-off.
private IActorRef? _attributeSourceActor;
private Source<ISiteStreamEvent, NotUsed>? _attributeHubSource;
@@ -68,13 +67,13 @@ public class SiteStreamManager : ISiteStreamSubscriber
/// <summary>
/// Cumulative count of alarm state changes dropped because
/// <see cref="_alarmPublishQueue"/> was at capacity (WP2.6d) — i.e. the site-wide
/// <see cref="_alarmPublishQueue"/> was at capacity — i.e. the site-wide
/// alarm hand-off queue itself fell behind, not the downstream per-subscriber
/// buffers. Surfaced on the site health report.
/// </summary>
public long AlarmPublishDroppedCount => Interlocked.Read(ref _alarmPublishDroppedCount);
// Per-source subscriber counts (WP2.6d "skip publish at zero subscribers").
// Per-source subscriber counts (skip publish at zero subscribers).
// A per-instance Subscribe touches BOTH sources (it needs both attribute and alarm
// events for that instance); SubscribeSiteAlarms touches only the alarm source.
private int _attributeSubscriberCount;
@@ -159,6 +158,9 @@ public class SiteStreamManager : ISiteStreamSubscriber
/// wiring can be verified deterministically, without racing the async pump/actor
/// system against a burst of publishes.
/// </summary>
/// <param name="capacity">Maximum number of queued alarm events before the oldest is evicted.</param>
/// <param name="onDropped">Callback invoked on every eviction.</param>
/// <returns>The bounded alarm hand-off channel.</returns>
internal static Channel<AlarmStateChanged> CreateAlarmPublishQueue(int capacity, Action onDropped) =>
Channel.CreateBounded<AlarmStateChanged>(
new BoundedChannelOptions(Math.Max(1, capacity))
@@ -172,7 +174,7 @@ public class SiteStreamManager : ISiteStreamSubscriber
/// <summary>
/// Publishes an attribute value change to the attribute broadcast hub.
/// Fire-and-forget — never blocks the calling actor. Skipped entirely when no
/// subscriber is currently interested in attribute events (WP2.6d).
/// subscriber is currently interested in attribute events.
/// </summary>
/// <param name="changed">The attribute value change event to publish.</param>
public void PublishAttributeValueChanged(AttributeValueChanged changed)
@@ -188,7 +190,7 @@ public class SiteStreamManager : ISiteStreamSubscriber
/// from the (far higher-volume) attribute path, so an attribute storm can never
/// evict a pending alarm transition. Fire-and-forget — never blocks the calling
/// actor. Skipped entirely when no subscriber is currently interested in alarm
/// events (WP2.6d).
/// events.
/// </summary>
/// <param name="changed">The alarm state change event to publish.</param>
public void PublishAlarmStateChanged(AlarmStateChanged changed)
@@ -247,17 +249,7 @@ public class SiteStreamManager : ISiteStreamSubscriber
return subscriptionId;
}
/// <summary>
/// Subscribe to ALARM events for ALL instances on the site (no per-instance
/// filter). The dedicated alarm hub carries only <see cref="AlarmStateChanged"/>
/// events (the <c>Where</c> below is a defensive no-op, not a load-bearing filter —
/// see the type-level remarks); the aggregated Alarm Summary never sees attribute
/// events. Same broadcast-hub wiring as <see cref="Subscribe"/>, and the returned
/// subscription id is torn down via <see cref="Unsubscribe"/> exactly like the
/// per-instance variant.
/// </summary>
/// <param name="subscriber">The actor that receives forwarded <see cref="AlarmStateChanged"/> events.</param>
/// <returns>A subscription id to pass to <see cref="Unsubscribe"/>.</returns>
/// <inheritdoc />
public string SubscribeSiteAlarms(IActorRef subscriber)
{
if (_alarmHubSource is null || _materializer is null)
@@ -10,7 +10,7 @@ public class OperationTrackingOptions
/// MIGRATION-ONLY as of LocalDb Phase 1. <see cref="OperationTrackingStore"/> no longer
/// reads this: the tracking table lives in the consolidated site database at
/// <c>LocalDb:Path</c>. The only remaining consumer is
/// <c>SiteLocalDbLegacyMigrator</c>, which uses it to locate a pre-Phase-1
/// <c>SiteLocalDbLegacyMigrator</c>, which uses it to locate a pre-LocalDb
/// <c>site-tracking.db</c> and copy it in once. Safe to delete from a config once that
/// node's migration has run.
/// </summary>
@@ -278,6 +278,8 @@ public class OperationTrackingStore : IOperationTrackingStore, IAsyncDisposable,
/// take this barrier implicitly; it is public for callers that go around the
/// store to the file (tests, tooling) and for anyone who wants "durable now".
/// </summary>
/// <param name="ct">Token to cancel the wait.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task FlushAsync(CancellationToken ct = default) => WaitForQueuedWritesAsync(ct);
/// <summary>Queues a lifecycle write and returns once it is queued (not applied).</summary>