feat(siteruntime): InstanceActor spawns NativeAlarmActors + enriched alarm snapshot; clear native state on redeploy/undeploy

This commit is contained in:
Joseph Doherty
2026-05-31 02:06:39 -04:00
parent 376dac4895
commit 6d318586d1
4 changed files with 203 additions and 19 deletions
@@ -422,8 +422,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
command.RevisionHash,
isEnabled: true);
// Static overrides are reset on redeployment per design decision
// 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.
await _storage.ClearStaticOverridesAsync(instanceName);
await _storage.ClearNativeAlarmsForInstanceAsync(instanceName);
// Replicate to standby node
_replicationActor?.Tell(new ReplicateConfigDeploy(
@@ -48,6 +48,13 @@ public class InstanceActor : ReceiveActor
private readonly Dictionary<string, int> _alarmPriorities = new();
private readonly Dictionary<string, IActorRef> _scriptActors = new();
private readonly Dictionary<string, IActorRef> _alarmActors = new();
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
/// <summary>
/// Latest enriched <see cref="AlarmStateChanged"/> per alarm name (computed and
/// native), so the DebugView snapshot carries the unified condition + native
/// metadata rather than a bare State/Priority projection.
/// </summary>
private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new();
private FlattenedConfiguration? _configuration;
// DCL manager actor reference for subscribing to tag values
@@ -505,6 +512,9 @@ public class InstanceActor : ReceiveActor
{
_alarmStates[changed.AlarmName] = changed.State;
_alarmTimestamps[changed.AlarmName] = changed.Timestamp;
// Retain the full enriched event (Kind, Condition, native metadata) so the
// DebugView snapshot reflects it — native alarms have no _alarmActors entry.
_latestAlarmEvents[changed.AlarmName] = changed;
// WP-23: Publish to site-wide stream
_streamManager?.PublishAlarmStateChanged(changed);
@@ -525,17 +535,10 @@ public class InstanceActor : ReceiveActor
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
_attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();
var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged(
_instanceUniqueName,
name,
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
_alarmPriorities.GetValueOrDefault(name, 0),
_alarmTimestamps[name])).ToList();
var snapshot = new DebugViewSnapshot(
_instanceUniqueName,
attributeValues,
alarmStates,
BuildAlarmStatesSnapshot(),
DateTimeOffset.UtcNow);
Sender.Tell(snapshot);
@@ -545,6 +548,34 @@ public class InstanceActor : ReceiveActor
_instanceUniqueName, request.CorrelationId);
}
/// <summary>
/// Builds the alarm-state list for a DebugView snapshot. Prefers the latest
/// enriched <see cref="AlarmStateChanged"/> per alarm (computed alarms that have
/// fired plus all native alarms) and falls back to a bare Normal projection for
/// computed alarms that have not yet emitted an event.
/// </summary>
private List<AlarmStateChanged> BuildAlarmStatesSnapshot()
{
var states = _latestAlarmEvents.Values.ToList();
foreach (var name in _alarmActors.Keys)
{
if (_latestAlarmEvents.ContainsKey(name))
{
continue;
}
states.Add(new AlarmStateChanged(
_instanceUniqueName,
name,
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
_alarmPriorities.GetValueOrDefault(name, 0),
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)));
}
return states;
}
/// <summary>
/// WP-25: Debug view unsubscribe (SiteRuntime-013).
/// This handler is a deliberate no-op acknowledgement: the Instance Actor holds
@@ -576,17 +607,10 @@ public class InstanceActor : ReceiveActor
_attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
_attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();
var alarmStates = _alarmActors.Keys.Select(name => new AlarmStateChanged(
_instanceUniqueName,
name,
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
_alarmPriorities.GetValueOrDefault(name, 0),
_alarmTimestamps[name])).ToList();
var snapshot = new DebugViewSnapshot(
_instanceUniqueName,
attributeValues,
alarmStates,
BuildAlarmStatesSnapshot(),
DateTimeOffset.UtcNow);
Sender.Tell(snapshot);
@@ -747,9 +771,55 @@ public class InstanceActor : ReceiveActor
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
}
// Create Native Alarm Actors — read-only mirror of each bound source's
// native alarms (peers to the computed Alarm Actors). They subscribe through
// the DCL manager, so without one (e.g. in isolated tests) they are skipped.
foreach (var nativeSource in _configuration.NativeAlarmSources)
{
if (_dclManager == null)
{
_logger.LogWarning(
"Instance {Instance}: native alarm source {Source} skipped — no DCL manager available",
_instanceUniqueName, nativeSource.CanonicalName);
continue;
}
var nativeKind = ResolveNativeKind(nativeSource.ConnectionName);
var props = Props.Create(() => new NativeAlarmActor(
nativeSource,
_instanceUniqueName,
Self,
_dclManager,
_storage,
_options,
_logger,
nativeKind));
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
}
_logger.LogInformation(
"Instance {Instance}: created {Scripts} script actors and {Alarms} alarm actors",
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
"Instance {Instance}: created {Scripts} script actors, {Alarms} alarm actors, {NativeAlarms} native alarm actors",
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count, _nativeAlarmActors.Count);
}
/// <summary>
/// Maps a bound connection's protocol to the native alarm kind stamped on
/// emitted events. MxAccess Gateway connections yield
/// <see cref="AlarmKind.NativeMxAccess"/>; everything else (OPC UA) yields
/// <see cref="AlarmKind.NativeOpcUa"/>.
/// </summary>
private AlarmKind ResolveNativeKind(string connectionName)
{
var protocol = _configuration?.Connections is { } connections
&& connections.TryGetValue(connectionName, out var cfg)
? cfg.Protocol
: null;
return protocol != null && protocol.Contains("Mx", StringComparison.OrdinalIgnoreCase)
? AlarmKind.NativeMxAccess
: AlarmKind.NativeOpcUa;
}
/// <summary>
@@ -244,6 +244,14 @@ public class SiteStorageService
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.Transaction = (SqliteTransaction)transaction;
cmd.CommandText = "DELETE FROM native_alarm_state WHERE instance_unique_name = @name";
cmd.Parameters.AddWithValue("@name", instanceName);
await cmd.ExecuteNonQueryAsync();
}
await using (var cmd = connection.CreateCommand())
{
cmd.Transaction = (SqliteTransaction)transaction;