feat(historian): emit PermanentFail for poison alarm events via additive PerEventStatus sidecar IPC field

This commit is contained in:
Joseph Doherty
2026-06-18 12:30:14 -04:00
parent f320f323ae
commit feddc2b80e
6 changed files with 438 additions and 21 deletions
@@ -302,6 +302,95 @@ public sealed class WonderwareHistorianClientTests
outcomes[1].ShouldBe(HistorianWriteOutcome.RetryPlease);
}
/// <summary>
/// The granular PerEventStatus wire field maps directly: 0→Ack, 1→Retry, 2→PermanentFail.
/// A poison event the sidecar marks Permanent (status 2) must dead-letter via
/// <see cref="HistorianWriteOutcome.PermanentFail"/> rather than retrying.
/// </summary>
[Fact]
public async Task WriteBatchAsync_PerEventStatusPermanent_MapsToPermanentFail()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [2], // Permanent
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-poison", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(1);
outcomes[0].ShouldBe(HistorianWriteOutcome.PermanentFail);
}
/// <summary>
/// PerEventStatus = 0 maps to <see cref="HistorianWriteOutcome.Ack"/>; the granular path
/// takes precedence over the legacy PerEventOk bool when both are present.
/// </summary>
[Fact]
public async Task WriteBatchAsync_PerEventStatusAck_MapsToAck()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [0], // Ack
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-ok", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(1);
outcomes[0].ShouldBe(HistorianWriteOutcome.Ack);
}
/// <summary>
/// Rolling-deploy back-compat: an older sidecar that sends an empty PerEventStatus but a
/// populated PerEventOk must still classify via the legacy bool path (false→RetryPlease).
/// </summary>
[Fact]
public async Task WriteBatchAsync_EmptyPerEventStatus_FallsBackToLegacyPerEventOk()
{
await using var server = new FakeSidecarServer(Secret)
{
OnWriteAlarmEvents = _ => new WriteAlarmEventsReply
{
Success = true,
PerEventStatus = [], // older sidecar — no granular status
PerEventOk = [false],
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var batch = new[]
{
new AlarmHistorianEvent("ev-legacy", "Tank/HiHi", "HiHi", "LimitAlarm", AlarmSeverity.High, "Activated", "msg", "u", null, DateTime.UtcNow),
};
var outcomes = await client.WriteBatchAsync(batch, CancellationToken.None);
outcomes.Count.ShouldBe(1);
outcomes[0].ShouldBe(HistorianWriteOutcome.RetryPlease);
}
/// <summary>Verifies that Hello handshake throws UnauthorizedAccessException on secret mismatch.</summary>
[Fact]
public async Task Hello_BadSecret_ThrowsUnauthorizedAccess()