fix(alarms): #647 review — Raise on an active condition is a re-statement; FOCAS outage quality + health
MAJOR-1: a deploy that changes a driver's config respawns it, and the fresh instance's empty told record re-sent Raise for every still-active alarm, un-acking acknowledged conditions and writing a duplicate "Activated" alerts row. NativeAlarmProjector now treats Raise on an already-active condition as a re-statement (keeps Acked; ForwardNativeAlarm skips the alerts row); only Retrigger (or inactive->active) un-acks. Sources whose upstream ack state is authoritative send Retrigger for an unacked re-statement: OpcUaClient on first sight (no prior told), Galaxy for an unacked active snapshot entry. Runtime E2E drives a FRESH FOCAS driver after a redeploy and asserts the ack survives. MAJOR-2: a failed FOCAS alarm read annotates each subscribed condition BadNoCommunication once per outage (#626 quality-only path) with one Warning (MINOR-6), lifted to Good on recovery; failed alarm reads degrade driver health with LastSuccessfulRead frozen (successful ones advance it), so an alarm-only driver trips the #527 comms-loss watchdog. MINOR-3 CLAUDE.md #630 bullet; MINOR-4 split line; MINOR-5 a non-empty cnc_rdalmmsg2 payload that fits neither stride throws; MINOR-7 value-tag + parsed-address tables are built and swapped, never cleared in place under the retained poll engine. Docs corrected (FOCAS.md, AlarmTracking.md, CLAUDE.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -62,10 +62,14 @@ public sealed class FocasAlarmProjectionTests
|
||||
private static void Tick(FocasDriver drv, params FocasActiveAlarm[] alarms) =>
|
||||
drv.AlarmProjection.Tick(drv.AlarmProjection.Current!, Host, alarms, drv.AlarmTags);
|
||||
|
||||
private static List<AlarmEventArgs> Capture(FocasDriver drv)
|
||||
private static List<AlarmEventArgs> Capture(FocasDriver drv, bool transitionsOnly = false)
|
||||
{
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
drv.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
if (transitionsOnly && e.IsQualityOnly) return;
|
||||
lock (events) events.Add(e);
|
||||
};
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -277,7 +281,8 @@ public sealed class FocasAlarmProjectionTests
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var events = Capture(drv);
|
||||
// Transitions only: the outage's quality-only BadNoCommunication annotation is pinned separately.
|
||||
var events = Capture(drv, transitionsOnly: true);
|
||||
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 0);
|
||||
var client = ClientOf(factory);
|
||||
@@ -421,6 +426,125 @@ public sealed class FocasAlarmProjectionTests
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- Gitea #647 review MAJOR-2: an unreachable alarm source must say so ----------------------------------
|
||||
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
private static void TickFailed(FocasDriver drv) =>
|
||||
drv.AlarmProjection.TickFailed(drv.AlarmProjection.Current!, Host, drv.AlarmTags, new InvalidOperationException("down"));
|
||||
|
||||
/// <summary>
|
||||
/// A failed read marks each subscribed condition BadNoCommunication ONCE per outage, quality-only (no Clear,
|
||||
/// no state change); recovery lifts it to Good and, with the alarm unchanged, re-raises nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_failed_read_annotates_quality_once_and_recovery_lifts_it_without_a_re_raise()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
|
||||
var events = Capture(drv);
|
||||
Tick(drv, Servo401());
|
||||
events.Clear();
|
||||
|
||||
TickFailed(drv);
|
||||
TickFailed(drv);
|
||||
events.Count.ShouldBe(2, "one Bad per condition for the whole outage");
|
||||
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == BadNoCommunication);
|
||||
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath });
|
||||
|
||||
events.Clear();
|
||||
Tick(drv, Servo401());
|
||||
events.Count.ShouldBe(2);
|
||||
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == 0u, "recovery lifts the Bad and says nothing else");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_recovery_that_finds_the_alarm_cleared_lifts_quality_then_clears()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||
var events = Capture(drv);
|
||||
Tick(drv, Servo401());
|
||||
TickFailed(drv);
|
||||
events.Clear();
|
||||
|
||||
Tick(drv);
|
||||
|
||||
events.Select(e => (e.Kind, e.QualityStatusCode)).ToArray().ShouldBe(new (AlarmTransitionKind, uint?)[]
|
||||
{
|
||||
(AlarmTransitionKind.Unspecified, 0u),
|
||||
(AlarmTransitionKind.Clear, null),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_persistently_failing_alarm_read_warns_once_per_outage()
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||
RawTags = [Tag(ServoPath, "ALARM:Servo")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory, logger);
|
||||
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
|
||||
Capture(drv);
|
||||
var before = logger.Warnings();
|
||||
|
||||
TickFailed(drv);
|
||||
TickFailed(drv);
|
||||
TickFailed(drv);
|
||||
logger.Warnings().ShouldBe(before + 1);
|
||||
|
||||
Tick(drv); // recovery ends the episode…
|
||||
TickFailed(drv); // …so the next outage warns again
|
||||
logger.Warnings().ShouldBe(before + 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An alarm-only driver must be able to trip the comms-loss watchdog (#527): sustained alarm-read failure
|
||||
/// goes non-Healthy with LastSuccessfulRead FROZEN; a successful alarm read advances it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sustained_alarm_read_failure_degrades_health_with_LastSuccessfulRead_frozen()
|
||||
{
|
||||
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var atInit = drv.GetHealth().LastSuccessfulRead;
|
||||
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
|
||||
|
||||
var failing = drv.GetHealth();
|
||||
failing.State.ShouldBe(DriverState.Degraded);
|
||||
failing.LastSuccessfulRead.ShouldBe(atInit, "a failed read never advances LastSuccessfulRead");
|
||||
|
||||
factory.Clients[0].ThrowOnReadAlarms = false;
|
||||
await WaitFor(() => drv.GetHealth().State == DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger<FocasDriver>
|
||||
{
|
||||
private int _warnings;
|
||||
|
||||
public int Warnings() => Volatile.Read(ref _warnings);
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning
|
||||
&& formatter(state, exception).Contains("cnc_rdalmmsg2", StringComparison.Ordinal))
|
||||
Interlocked.Increment(ref _warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static int Count(List<AlarmEventArgs> events)
|
||||
{
|
||||
lock (events) return events.Count;
|
||||
|
||||
@@ -133,6 +133,20 @@ public sealed class FocasAlarmSelectorTests
|
||||
c.Alarm.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 review MINOR-5 — a non-empty alarm payload that fits neither record stride is malformed, not
|
||||
/// "no alarms" (which would mass-clear every condition on the device). Empty stays empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_malformed_alarm_payload_throws_while_an_empty_one_is_no_alarms()
|
||||
{
|
||||
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
|
||||
FocasWireClient.ParseAlarms(new byte[76], 32).Count.ShouldBe(1);
|
||||
FocasWireClient.ParseAlarms(new byte[160], 32).Count.ShouldBe(2);
|
||||
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
|
||||
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[12], 32));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 defect 4 — a read that FAILED must not look like "no alarms": the projection would diff it
|
||||
/// to a mass clear and re-raise everything un-acknowledged on recovery. A disconnected wire client throws.
|
||||
|
||||
+2
-2
@@ -58,9 +58,9 @@ public sealed class GatewayGalaxyAlarmFeedTests
|
||||
|
||||
observed.Count.ShouldBe(3);
|
||||
|
||||
// Active snapshot entry → Raise.
|
||||
// Active (unacked) snapshot entry → Retrigger (#647 review: forces the upstream's unack on the host).
|
||||
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Retrigger);
|
||||
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
||||
observed[0].RawMxAccessSeverity.ShouldBe(750);
|
||||
|
||||
|
||||
@@ -232,6 +232,21 @@ public sealed class OpcUaClientAlarmTests
|
||||
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 review — with no prior this driver told the host (first sight after start or a deploy respawn),
|
||||
/// an unacknowledged upstream target goes out as Retrigger, never Raise: the host keeps an existing ack across
|
||||
/// a Raise on an already-active condition, so only a Retrigger makes the host match the upstream's unack.
|
||||
/// An acknowledged target keeps its Raise (a re-statement there is exactly right).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
|
||||
|
||||
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
|
||||
@@ -357,7 +372,9 @@ public sealed class OpcUaClientAlarmTests
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
// First sight of an UNACKED upstream condition is a Retrigger (#647 review: the host keeps an ack across a
|
||||
// Raise on an already-active condition, and only the upstream knows it is unacknowledged).
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Acknowledge]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
||||
seen.Count.ShouldBe(2);
|
||||
|
||||
Reference in New Issue
Block a user