feat(site-runtime): stamp IsMesReportable on the Instance Actor alarm snapshot
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
@@ -107,4 +107,13 @@ public record AlarmStateChanged(
|
||||
/// show the binding node as "no active conditions". Additive; default false.
|
||||
/// </summary>
|
||||
public bool IsConfiguredPlaceholder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the alarm's deployed configuration flags it as MES-reportable
|
||||
/// (<c>ResolvedAlarm.IsMesReportable</c>). Stamped by the Instance Actor on its alarm
|
||||
/// snapshot; always false for native conditions, which have no per-alarm config row.
|
||||
/// NOT carried on the gRPC stream or the debug-snapshot DTO in this slice (no proto
|
||||
/// field on either) — central sees the default. Additive; default false.
|
||||
/// </summary>
|
||||
public bool IsMesReportable { get; init; }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,16 @@ public class InstanceActor : ReceiveActor
|
||||
private readonly Dictionary<string, AlarmState> _alarmStates = new();
|
||||
private readonly Dictionary<string, DateTimeOffset> _alarmTimestamps = new();
|
||||
private readonly Dictionary<string, int> _alarmPriorities = new();
|
||||
|
||||
/// <summary>
|
||||
/// Computed-alarm canonical name → the deployed <c>ResolvedAlarm.IsMesReportable</c>
|
||||
/// flag. Populated alongside <see cref="_alarmPriorities"/> when the Alarm Actor
|
||||
/// children are created and read back in <see cref="BuildAlarmStatesSnapshot"/>, which
|
||||
/// is the single place the flag is stamped — so a fired alarm (served from the retained
|
||||
/// event) and an unfired one (served from the bare fallback projection) cannot drift.
|
||||
/// Native conditions have no entry here and correctly resolve to false.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, bool> _alarmMesReportable = new();
|
||||
private readonly Dictionary<string, IActorRef> _scriptActors = new();
|
||||
private readonly Dictionary<string, IActorRef> _alarmActors = new();
|
||||
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
|
||||
@@ -1568,7 +1578,16 @@ public class InstanceActor : ReceiveActor
|
||||
/// </summary>
|
||||
private List<AlarmStateChanged> BuildAlarmStatesSnapshot()
|
||||
{
|
||||
var states = _latestAlarmEvents.Values.ToList();
|
||||
// The MES-reportable flag is stamped HERE and nowhere else (not at the Alarm
|
||||
// Actor's emit sites), so the retained-event rows below and the fallback rows
|
||||
// that follow always agree on it. A native condition has no _alarmMesReportable
|
||||
// entry and is returned as-is (no record copy) — the default false is the intended
|
||||
// result, and native rows dominate this list on a busy binding.
|
||||
var states = _latestAlarmEvents.Values
|
||||
.Select(e => _alarmMesReportable.TryGetValue(e.AlarmName, out var mes) && mes
|
||||
? e with { IsMesReportable = true }
|
||||
: e)
|
||||
.ToList();
|
||||
|
||||
foreach (var name in _alarmActors.Keys)
|
||||
{
|
||||
@@ -1582,7 +1601,10 @@ public class InstanceActor : ReceiveActor
|
||||
name,
|
||||
_alarmStates.GetValueOrDefault(name, AlarmState.Normal),
|
||||
_alarmPriorities.GetValueOrDefault(name, 0),
|
||||
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)));
|
||||
_alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow))
|
||||
{
|
||||
IsMesReportable = _alarmMesReportable.GetValueOrDefault(name)
|
||||
});
|
||||
}
|
||||
|
||||
// A native binding is "already represented" if any retained event carries its
|
||||
@@ -1980,6 +2002,7 @@ public class InstanceActor : ReceiveActor
|
||||
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
|
||||
_alarmActors[alarm.CanonicalName] = actorRef;
|
||||
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
|
||||
_alarmMesReportable[alarm.CanonicalName] = alarm.IsMesReportable;
|
||||
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
|
||||
RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration));
|
||||
}
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// The deployed alarm's <c>IsMesReportable</c> flag must reach the site-local alarm
|
||||
/// snapshot that <c>Alarms.CurrentAsync()</c> and the Debug View query both build (the
|
||||
/// flag does not cross <c>DebugAlarmStateDto</c> to central in this slice). It is stamped in ONE
|
||||
/// place — <c>BuildAlarmStatesSnapshot</c> — so a computed alarm that has fired (served
|
||||
/// from the retained <c>AlarmStateChanged</c>) and one that never has (served from the
|
||||
/// bare Normal fallback) can never disagree about the flag.
|
||||
/// </summary>
|
||||
public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly SiteRuntimeOptions _options = new();
|
||||
private readonly TestLocalDb _localDb;
|
||||
|
||||
public InstanceActorMesReportableSnapshotTests()
|
||||
{
|
||||
_localDb = TestLocalDb.CreateTemp("instance-mes-reportable");
|
||||
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
||||
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||
_sharedScriptLibrary = new SharedScriptLibrary(_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
}
|
||||
|
||||
private IActorRef CreateInstanceActor(string instanceName, FlattenedConfiguration config) =>
|
||||
ActorOf(Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
JsonSerializer.Serialize(config),
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
null,
|
||||
_options,
|
||||
NullLogger<InstanceActor>.Instance,
|
||||
null)));
|
||||
|
||||
/// <summary>
|
||||
/// One static attribute driving two computed alarms: <c>HighTemp</c> is flagged
|
||||
/// MES-reportable and fires at 80; <c>TempSpike</c> is not flagged and only matches
|
||||
/// the exact value 100, so it never fires in this test.
|
||||
/// </summary>
|
||||
private static FlattenedConfiguration TwoComputedAlarmsConfig(string instanceName) => new()
|
||||
{
|
||||
InstanceUniqueName = instanceName,
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Temperature",
|
||||
Value = "20",
|
||||
DataType = "Double"
|
||||
}
|
||||
],
|
||||
Alarms =
|
||||
[
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "HighTemp",
|
||||
TriggerType = "HiLo",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"hi\":80}",
|
||||
PriorityLevel = 700,
|
||||
IsMesReportable = true
|
||||
},
|
||||
new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = "TempSpike",
|
||||
TriggerType = "ValueMatch",
|
||||
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}",
|
||||
PriorityLevel = 300,
|
||||
IsMesReportable = false
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private GetAlarmSnapshotResponse AskSnapshot(IActorRef actor, string instanceName)
|
||||
{
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
actor.Tell(new GetAlarmSnapshotRequest(correlationId, instanceName, DateTimeOffset.UtcNow));
|
||||
// Match on the correlation id so a late reply from a timed-out probe can never be
|
||||
// consumed as the "current" snapshot by a later call; keep the inner wait well
|
||||
// inside the AwaitAssert budget so one slow probe doesn't eat the whole retry window.
|
||||
return ExpectMsg<GetAlarmSnapshotResponse>(
|
||||
m => m.CorrelationId == correlationId, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmSnapshot_StampsIsMesReportable_OnFiredAndUnfiredAlarms()
|
||||
{
|
||||
const string instance = "Oven1";
|
||||
var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance));
|
||||
|
||||
// Drive Temperature above the Hi setpoint: HighTemp fires, TempSpike does not.
|
||||
actor.Tell(new SetStaticAttributeCommand(
|
||||
"corr-temp", instance, "Temperature", "90", DateTimeOffset.UtcNow));
|
||||
var write = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(write.Success);
|
||||
|
||||
// The alarm child evaluates asynchronously — poll the snapshot until the
|
||||
// retained event for HighTemp has landed.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var pending = AskSnapshot(actor, instance);
|
||||
var firing = Assert.Single(pending.Alarms, a => a.AlarmName == "HighTemp");
|
||||
Assert.Equal(AlarmState.Active, firing.State);
|
||||
}, TimeSpan.FromSeconds(5), TimeSpan.FromMilliseconds(100));
|
||||
|
||||
var snapshot = AskSnapshot(actor, instance);
|
||||
|
||||
// Both rows are still returned — stamping must not drop or duplicate anything.
|
||||
Assert.Equal(2, snapshot.Alarms.Count);
|
||||
|
||||
// Fired alarm: served from the retained AlarmStateChanged.
|
||||
var fired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp");
|
||||
Assert.Equal(AlarmState.Active, fired.State);
|
||||
Assert.True(fired.IsMesReportable);
|
||||
|
||||
// Never-fired alarm: served from the bare Normal fallback projection.
|
||||
var unfired = Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike");
|
||||
Assert.Equal(AlarmState.Normal, unfired.State);
|
||||
Assert.False(unfired.IsMesReportable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmSnapshot_UnfiredReportableAlarm_IsStampedBeforeItEverFires()
|
||||
{
|
||||
// The fallback branch must carry the flag on its own: an alarm that has never
|
||||
// emitted an event is still MES-reportable per its deployed configuration.
|
||||
const string instance = "Oven2";
|
||||
var actor = CreateInstanceActor(instance, TwoComputedAlarmsConfig(instance));
|
||||
|
||||
var snapshot = AskSnapshot(actor, instance);
|
||||
|
||||
Assert.Equal(2, snapshot.Alarms.Count);
|
||||
Assert.True(Assert.Single(snapshot.Alarms, a => a.AlarmName == "HighTemp").IsMesReportable);
|
||||
Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||
// then dispose the database before deleting — the master connection anchors the WAL.
|
||||
Shutdown();
|
||||
var path = _localDb.Path;
|
||||
_localDb.Dispose();
|
||||
TestLocalDb.DeleteFiles(path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user