fix(siteeventlog): suppress snapshot-resync alarm re-emit + coverage + hardening (review)

This commit is contained in:
Joseph Doherty
2026-06-15 12:45:00 -04:00
parent e74c3aef23
commit e5534fddca
6 changed files with 166 additions and 23 deletions
@@ -219,24 +219,27 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000);
// The deployment site events are emitted fire-and-forget off the actor
// thread (LogDeploymentEvent runs in a ContinueWith continuation), so
// poll for each event with AwaitAssert rather than a bare Task.Delay —
// a fixed sleep is racy under CI load.
actor.Tell(new DisableInstanceCommand("cmd-de1", "EvtPump", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5)).Success);
await Task.Delay(300);
AwaitAssert(() => Assert.Contains(siteLog.OfType("deployment"),
r => r.Message.Contains("disabled", StringComparison.OrdinalIgnoreCase)),
TimeSpan.FromSeconds(2));
actor.Tell(new EnableInstanceCommand("cmd-en1", "EvtPump", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5)).Success);
await Task.Delay(300);
AwaitAssert(() => Assert.Contains(siteLog.OfType("deployment"),
r => r.Message.Contains("enabled", StringComparison.OrdinalIgnoreCase)),
TimeSpan.FromSeconds(2));
actor.Tell(new DeleteInstanceCommand("cmd-del-evt", "EvtPump", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5)).Success);
AwaitAssert(() =>
{
var rows = siteLog.OfType("deployment");
Assert.Contains(rows, r => r.Message.Contains("disabled", StringComparison.OrdinalIgnoreCase));
Assert.Contains(rows, r => r.Message.Contains("enabled", StringComparison.OrdinalIgnoreCase));
Assert.Contains(rows, r => r.Message.Contains("deleted", StringComparison.OrdinalIgnoreCase));
}, TimeSpan.FromSeconds(2));
AwaitAssert(() => Assert.Contains(siteLog.OfType("deployment"),
r => r.Message.Contains("deleted", StringComparison.OrdinalIgnoreCase)),
TimeSpan.FromSeconds(2));
}
[Fact]
@@ -1,3 +1,4 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
@@ -180,6 +181,101 @@ public class NativeAlarmActorTests : TestKit, IDisposable
}, TimeSpan.FromSeconds(2));
}
[Fact]
public async Task Rehydration_DoesNotEmitSiteEvent()
{
// Pre-populate SQLite with an active condition so the actor rehydrates
// it on PreStart. Rehydration replays last-known state — it is NOT a
// live transition, so it must surface upward (for the DebugView) but
// must NOT re-log an `alarm` operational event.
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
await _storage.UpsertNativeAlarmAsync(
"inst", "Pressure", "T01.Hi",
JsonSerializer.Serialize(condition), DateTimeOffset.UtcNow);
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
// The rehydrated condition is surfaced upward...
var emitted = instance.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(2));
Assert.Equal("T01.Hi", emitted.SourceReference);
Assert.Equal(AlarmState.Active, emitted.State);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// ...but no `alarm` operational event is logged for it.
AwaitAssert(
() => Assert.Empty(siteLog.OfType("alarm")),
TimeSpan.FromSeconds(1));
}
[Fact]
public void SnapshotSwap_ExistingActiveCondition_DoesNotReEmit()
{
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// Live raise — the one and only `alarm` event we expect.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
AwaitAssert(() => Assert.Single(siteLog.OfType("alarm")), TimeSpan.FromSeconds(2));
// A reconnect snapshot that RE-INCLUDES the same still-active condition is
// a re-sync, not a live transition. It must NOT re-log a second `alarm`
// event (regression for the spurious-reconnect-event bug).
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Snapshot,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.SnapshotComplete,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
// The snapshot still surfaces the condition upward (DebugView re-sync)...
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "T01.Hi" && m.State == AlarmState.Active);
// ...but the `alarm` event count stays at exactly 1 — no re-emit.
Thread.Sleep(200); // give any spurious fire-and-forget log time to land
Assert.Single(siteLog.OfType("alarm"));
}
[Fact]
public void Acknowledge_EmitsInfoAlarmSiteEventMentioningAcknowledged()
{
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Operator acknowledges the still-active condition. The Acknowledge
// branch of LogAlarmEvent logs Info and mentions "acknowledged".
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Acknowledge,
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800), t0.AddSeconds(5))));
instance.ExpectMsg<AlarmStateChanged>();
AwaitAssert(() =>
{
var rows = siteLog.OfType("alarm");
Assert.Equal(2, rows.Count); // raise + acknowledge
var ack = rows[1];
Assert.Equal("Info", ack.Severity);
Assert.Contains("acknowledged", ack.Message, StringComparison.OrdinalIgnoreCase);
}, TimeSpan.FromSeconds(2));
}
void IDisposable.Dispose()
{
Shutdown();