worker(alarms): route ForcedMode/watch-list/failover via AlarmCommandHandler; emit provider-mode-changed event

This commit is contained in:
Joseph Doherty
2026-06-13 10:04:33 -04:00
parent 7241a4fb9c
commit 3f5e5fc0b3
9 changed files with 366 additions and 43 deletions
@@ -372,11 +372,11 @@ public sealed class AlarmCommandExecutorTests
public string? LastFilterPrefix { get; private set; }
/// <summary>Records a subscription.</summary>
/// <param name="subscription">The subscription expression.</param>
/// <param name="command">The subscribe-alarms command.</param>
/// <param name="sessionId">The session identifier.</param>
public void Subscribe(string subscription, string sessionId)
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{
LastSubscription = subscription;
LastSubscription = command.SubscriptionExpression;
LastSessionId = sessionId;
}
@@ -21,7 +21,7 @@ public sealed class AlarmCommandHandlerTests
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!Area", "session-1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area" }, "session-1");
Assert.True(handler.IsSubscribed);
Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription);
@@ -36,9 +36,9 @@ public sealed class AlarmCommandHandlerTests
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!B", "s1"));
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!B" }, "s1"));
}
/// <summary>
@@ -63,7 +63,7 @@ public sealed class AlarmCommandHandlerTests
() => consumer);
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!A", "s1"));
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"));
Assert.Contains("simulated wnwrap subscribe failure", exception.Message);
Assert.False(handler.IsSubscribed);
Assert.True(consumer.Disposed);
@@ -77,7 +77,7 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.Unsubscribe();
@@ -104,7 +104,7 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Guid g = Guid.NewGuid();
int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F");
@@ -149,7 +149,7 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null);
@@ -173,7 +173,7 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
IReadOnlyList<ActiveAlarmSnapshot> filtered = handler.QueryActive("Galaxy!AreaA");
@@ -189,13 +189,13 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.Dispose();
Assert.True(consumer.Disposed);
Assert.Throws<ObjectDisposedException>(
() => handler.Subscribe("x", "y"));
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = "x" }, "y"));
}
/// <summary>
@@ -218,7 +218,7 @@ public sealed class AlarmCommandHandlerTests
// factory is invoked. We tally invocation counts after each call so
// that a missed guard surfaces as the diagnostic count, not a generic
// "Subscribe should have failed".
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(1, guardInvocations);
handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F");
@@ -254,7 +254,7 @@ public sealed class AlarmCommandHandlerTests
// Subscribe: guard runs before the dispatcher is constructed.
Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!A", "s1"));
() => handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"));
// To exercise the other entry points we need a subscribed handler.
// Construct a parallel handler with a passing guard, then swap in a
@@ -273,6 +273,132 @@ public sealed class AlarmCommandHandlerTests
Assert.Throws<InvalidOperationException>(() => handler.Unsubscribe());
}
/// <summary>
/// Worker-9: ForcedMode=Subtag builds a subtag consumer (via the
/// injected standby factory) and advises it — the primary
/// (alarmmgr) consumer is NOT created.
/// </summary>
[Fact]
public void Subscribe_WithForcedSubtagMode_BuildsStandbyConsumerOnly()
{
FakeConsumer primary = new FakeConsumer();
FakeConsumer standby = new FakeConsumer();
IReadOnlyList<AlarmSubtagTarget>? capturedWatchList = null;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: watch =>
{
capturedWatchList = watch;
return standby;
});
SubscribeAlarmsCommand command = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
ForcedMode = AlarmProviderMode.Subtag,
};
command.WatchList.Add(new AlarmSubtagTarget { AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi" });
handler.Subscribe(command, "s1");
Assert.True(handler.IsSubscribed);
Assert.Equal(@"\\HOST\Galaxy!Area", standby.LastSubscription); // standby advised
Assert.Null(primary.LastSubscription); // primary never built
Assert.NotNull(capturedWatchList);
Assert.Single(capturedWatchList!);
}
/// <summary>
/// Worker-9: ForcedMode=Unspecified + a non-empty watch list builds a
/// failover composite (primary + subtag standby). Forcing the primary
/// to fail on subscribe with a threshold of 1 drives the composite to
/// switch to the subtag provider, which must enqueue an
/// OnAlarmProviderModeChanged event carrying mode=Subtag.
/// </summary>
[Fact]
public void Subscribe_AutoModeWithWatchList_FailoverModeChange_EnqueuesProviderModeChangedEvent()
{
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true };
FakeConsumer standby = new FakeConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
AlarmCommandHandler handler = new AlarmCommandHandler(
queue,
() => primary,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: _ => standby);
SubscribeAlarmsCommand command = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
ForcedMode = AlarmProviderMode.Unspecified,
Failover = new AlarmFailoverConfig
{
ConsecutiveFailureThreshold = 1,
FailbackProbeIntervalSeconds = 1,
FailbackStableProbes = 1,
},
};
command.WatchList.Add(new AlarmSubtagTarget { AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi" });
// Subscribe: standby is armed cleanly; the primary subscribe throws and,
// at threshold 1, the failover composite switches to standby and raises
// ProviderModeChanged. The handler enqueues the proto event.
handler.Subscribe(command, "s1");
IReadOnlyList<WorkerEvent> drained = queue.Drain(0);
Assert.Single(drained);
MxEvent evt = drained[0].Event;
Assert.Equal(MxEventFamily.OnAlarmProviderModeChanged, evt.Family);
Assert.Equal("s1", evt.SessionId);
Assert.NotNull(evt.OnAlarmProviderModeChanged);
Assert.Equal(AlarmProviderMode.Subtag, evt.OnAlarmProviderModeChanged.Mode);
}
/// <summary>
/// Worker-9: a non-failover subscribe (alarmmgr-only) never enqueues a
/// provider-mode-changed event, and a subsequent Unsubscribe detaches
/// the handler so no event leaks.
/// </summary>
[Fact]
public void Subscribe_AlarmmgrOnly_DoesNotEnqueueProviderModeChangedEvent()
{
FakeConsumer consumer = new FakeConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
AlarmCommandHandler handler = new AlarmCommandHandler(queue, () => consumer);
handler.Subscribe(
new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
handler.Unsubscribe();
Assert.Empty(queue.Drain(0));
}
/// <summary>
/// Worker-9: the mapper builds a well-formed OnAlarmProviderModeChanged
/// MxEvent — correct family and populated body fields.
/// </summary>
[Fact]
public void Mapper_CreateOnAlarmProviderModeChanged_PopulatesFamilyAndBody()
{
MxAccessEventMapper mapper = new MxAccessEventMapper();
DateTime at = new DateTime(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
MxEvent evt = mapper.CreateOnAlarmProviderModeChanged(
"session-7", AlarmProviderMode.Subtag, "primary PollOnce failed", unchecked((int)0x80004005), at);
Assert.Equal(MxEventFamily.OnAlarmProviderModeChanged, evt.Family);
Assert.Equal("session-7", evt.SessionId);
Assert.NotNull(evt.OnAlarmProviderModeChanged);
Assert.Equal(AlarmProviderMode.Subtag, evt.OnAlarmProviderModeChanged.Mode);
Assert.Equal("primary PollOnce failed", evt.OnAlarmProviderModeChanged.Reason);
Assert.Equal(unchecked((int)0x80004005), evt.OnAlarmProviderModeChanged.Hresult);
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
}
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
{
return new MxAlarmSnapshotRecord
@@ -200,7 +200,7 @@ public sealed class MxAccessStaSessionTests
factory,
eventSink,
new MxAccessEventQueue(),
(_eq, _affinity) => handler);
(_eq, _affinity, _comFactory) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
@@ -279,7 +279,7 @@ public sealed class MxAccessStaSessionTests
factory,
eventSink,
new MxAccessEventQueue(),
(_eq, _affinity) => handler);
(_eq, _affinity, _comFactory) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
@@ -320,7 +320,7 @@ public sealed class MxAccessStaSessionTests
factory,
eventSink,
new MxAccessEventQueue(),
(_eq, _affinity) => handler);
(_eq, _affinity, _comFactory) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
@@ -369,7 +369,7 @@ public sealed class MxAccessStaSessionTests
factory,
eventSink,
eventQueue,
(_eq, _affinity) => handler);
(_eq, _affinity, _comFactory) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
@@ -416,7 +416,7 @@ public sealed class MxAccessStaSessionTests
factory,
eventSink,
eventQueue,
(_eq, _affinity) => handler);
(_eq, _affinity, _comFactory) => handler);
await session.StartAsync("session-1", workerProcessId: 1);
@@ -496,12 +496,12 @@ public sealed class MxAccessStaSessionTests
}
/// <summary>Subscribes to alarm events.</summary>
/// <param name="subscription">The subscription descriptor.</param>
/// <param name="command">The subscribe-alarms command.</param>
/// <param name="sessionId">The session identifier.</param>
public void Subscribe(string subscription, string sessionId)
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{
IsSubscribed = true;
LastSubscription = subscription;
LastSubscription = command.SubscriptionExpression;
}
/// <summary>Unsubscribes from alarm events.</summary>