feat(historian): emit PermanentFail for poison alarm events via additive PerEventStatus sidecar IPC field
This commit is contained in:
+89
@@ -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()
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog.Core;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the sidecar's poison-event classifier and the per-event status mapping in
|
||||
/// <see cref="HistorianFrameHandler"/>. A structurally-malformed alarm event is marked
|
||||
/// Permanent (status 2) and excluded from the writer batch so the store-and-forward sink
|
||||
/// dead-letters it immediately rather than looping to the retry cap; well-formed events
|
||||
/// map to Ack (0) / Retry (1) from the writer's per-event bool result.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianEventClassifierTests
|
||||
{
|
||||
/// <summary>Verifies a blank source name is classified structurally malformed.</summary>
|
||||
[Fact]
|
||||
public void IsStructurallyMalformed_BlankSourceName_IsTrue()
|
||||
{
|
||||
var e = WellFormed();
|
||||
e.SourceName = " ";
|
||||
|
||||
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a blank alarm type is classified structurally malformed.</summary>
|
||||
[Fact]
|
||||
public void IsStructurallyMalformed_BlankAlarmType_IsTrue()
|
||||
{
|
||||
var e = WellFormed();
|
||||
e.AlarmType = "";
|
||||
|
||||
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-positive event timestamp is classified structurally malformed.</summary>
|
||||
/// <param name="ticks">The event timestamp in ticks to test.</param>
|
||||
[Theory]
|
||||
[InlineData(0L)]
|
||||
[InlineData(-1L)]
|
||||
public void IsStructurallyMalformed_NonPositiveTimestamp_IsTrue(long ticks)
|
||||
{
|
||||
var e = WellFormed();
|
||||
e.EventTimeUtcTicks = ticks;
|
||||
|
||||
HistorianFrameHandler.IsStructurallyMalformed(e).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a well-formed event is not classified structurally malformed.</summary>
|
||||
[Fact]
|
||||
public void IsStructurallyMalformed_WellFormedEvent_IsFalse()
|
||||
{
|
||||
HistorianFrameHandler.IsStructurallyMalformed(WellFormed()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mixed batch — one poison event then one well-formed event the writer acks — must
|
||||
/// yield PerEventStatus = [2, 0]: the poison event is Permanent and excluded from the
|
||||
/// writer batch, and only the well-formed event reaches the writer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_MixedBatch_MarksPoisonPermanent_AndOnlyWritesWellFormed()
|
||||
{
|
||||
var poison = WellFormed();
|
||||
poison.EventId = "poison";
|
||||
poison.SourceName = ""; // structurally malformed
|
||||
|
||||
var good = WellFormed();
|
||||
good.EventId = "good";
|
||||
|
||||
var fakeWriter = new RecordingAlarmEventWriter(_ => true);
|
||||
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
|
||||
|
||||
var req = new WriteAlarmEventsRequest { Events = new[] { poison, good }, CorrelationId = "c1" };
|
||||
var reply = await RoundTripAsync(handler, req);
|
||||
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.PerEventStatus.ShouldBe(new byte[] { 2, 0 });
|
||||
reply.PerEventOk.ShouldBe(new[] { false, true });
|
||||
|
||||
// The writer only ever saw the well-formed event.
|
||||
fakeWriter.Received.Count.ShouldBe(1);
|
||||
fakeWriter.Received[0].EventId.ShouldBe("good");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A well-formed event the writer reports as not-persisted maps to Retry (status 1),
|
||||
/// not Permanent — only structurally-malformed events are Permanent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_WriterReportsNotPersisted_MapsToRetry()
|
||||
{
|
||||
var good = WellFormed();
|
||||
good.EventId = "good";
|
||||
|
||||
var fakeWriter = new RecordingAlarmEventWriter(_ => false);
|
||||
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
|
||||
|
||||
var req = new WriteAlarmEventsRequest { Events = new[] { good }, CorrelationId = "c2" };
|
||||
var reply = await RoundTripAsync(handler, req);
|
||||
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.PerEventStatus.ShouldBe(new byte[] { 1 });
|
||||
reply.PerEventOk.ShouldBe(new[] { false });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An all-poison batch must short-circuit the writer entirely (no WriteAsync call)
|
||||
/// and mark every slot Permanent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_AllPoison_SkipsWriter_AllPermanent()
|
||||
{
|
||||
var p1 = WellFormed();
|
||||
p1.SourceName = "";
|
||||
var p2 = WellFormed();
|
||||
p2.AlarmType = "";
|
||||
|
||||
var fakeWriter = new RecordingAlarmEventWriter(_ => true);
|
||||
var handler = new HistorianFrameHandler(new StubHistorian(), Logger.None, fakeWriter);
|
||||
|
||||
var req = new WriteAlarmEventsRequest { Events = new[] { p1, p2 }, CorrelationId = "c3" };
|
||||
var reply = await RoundTripAsync(handler, req);
|
||||
|
||||
reply.Success.ShouldBeTrue();
|
||||
reply.PerEventStatus.ShouldBe(new byte[] { 2, 2 });
|
||||
fakeWriter.Received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
private static AlarmHistorianEventDto WellFormed() => new()
|
||||
{
|
||||
EventId = "ev",
|
||||
SourceName = "Tank.HiHi",
|
||||
ConditionId = "HiHi",
|
||||
AlarmType = "LimitAlarm:Activated",
|
||||
Message = "msg",
|
||||
Severity = 700,
|
||||
EventTimeUtcTicks = new DateTime(2026, 6, 18, 12, 0, 0, DateTimeKind.Utc).Ticks,
|
||||
AckComment = null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Drives a WriteAlarmEvents request through the real frame handler over an in-memory
|
||||
/// duplex stream pair and deserializes the reply the handler writes back.
|
||||
/// </summary>
|
||||
private static async Task<WriteAlarmEventsReply> RoundTripAsync(
|
||||
HistorianFrameHandler handler, WriteAlarmEventsRequest req)
|
||||
{
|
||||
var capture = new MemoryStream();
|
||||
using var writer = new FrameWriter(capture, leaveOpen: true);
|
||||
|
||||
var body = MessagePackSerializer.Serialize(req);
|
||||
await handler.HandleAsync(MessageKind.WriteAlarmEventsRequest, body, writer, CancellationToken.None);
|
||||
|
||||
capture.Position = 0;
|
||||
using var reader = new FrameReader(capture, leaveOpen: true);
|
||||
var frame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
frame.ShouldNotBeNull();
|
||||
frame!.Value.Kind.ShouldBe(MessageKind.WriteAlarmEventsReply);
|
||||
return MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(frame.Value.Body);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IAlarmEventWriter"/> that records the batch it received and returns a fixed verdict.</summary>
|
||||
private sealed class RecordingAlarmEventWriter : IAlarmEventWriter
|
||||
{
|
||||
private readonly Func<AlarmHistorianEventDto, bool> _verdict;
|
||||
|
||||
/// <summary>Initializes a new instance with the given per-event verdict.</summary>
|
||||
/// <param name="verdict">Maps each received event to its persisted/not-persisted result.</param>
|
||||
public RecordingAlarmEventWriter(Func<AlarmHistorianEventDto, bool> verdict) => _verdict = verdict;
|
||||
|
||||
/// <summary>The events the writer was handed, in order.</summary>
|
||||
public List<AlarmHistorianEventDto> Received { get; } = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
|
||||
{
|
||||
Received.AddRange(events);
|
||||
return Task.FromResult(events.Select(_verdict).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A read data source the WriteAlarmEvents path never touches — present only to
|
||||
/// satisfy the <see cref="HistorianFrameHandler"/> ctor's non-null requirement.
|
||||
/// </summary>
|
||||
private sealed class StubHistorian : IHistorianDataSource
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<List<HistorianSample>> ReadRawAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(
|
||||
string tagName, DateTime[] timestamps, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<List<global::ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend.HistorianEventDto>> ReadEventsAsync(
|
||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user