fix(historian): preserve stable alarm delivery identities
v2-ci / build (pull_request) Successful in 5m52s
v2-ci / unit-tests (pull_request) Failing after 1h23m55s

This commit is contained in:
Joseph Doherty
2026-10-13 07:10:58 -04:00
parent 0ccfbbee5c
commit 109163621c
13 changed files with 341 additions and 27 deletions
@@ -18,6 +18,33 @@ public sealed class GatewayAlarmHistorianWriterTests
private static GatewayAlarmHistorianWriter Writer(FakeHistorianGatewayClient fake) =>
new(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
[Fact]
public async Task A_direct_retry_preserves_the_original_delivery_id()
{
var evt = Evt("A");
var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(StatusCode.Unavailable, "down")) };
using var writer = Writer(fake);
Assert.Equal(HistorianWriteOutcome.RetryPlease,
(await writer.WriteBatchAsync([evt], TestContext.Current.CancellationToken)).Single());
var first = fake.LastSendEvent!.Id;
fake.SendEventThrows = null;
fake.SendEventResult = new WriteAck { Success = true };
Assert.Equal(HistorianWriteOutcome.Ack,
(await writer.WriteBatchAsync([evt with { }], TestContext.Current.CancellationToken)).Single());
Assert.Equal(evt.DeliveryId.ToString("D"), first);
Assert.Equal(first, fake.LastSendEvent!.Id);
}
[Fact]
public async Task An_empty_delivery_id_is_permanent_failure_without_an_RPC()
{
var fake = new FakeHistorianGatewayClient();
using var writer = Writer(fake);
Assert.Equal(HistorianWriteOutcome.PermanentFail,
(await writer.WriteBatchAsync([Evt("A") with { DeliveryId = Guid.Empty }], TestContext.Current.CancellationToken)).Single());
Assert.Equal(0, fake.SendEventCallCount);
}
[Fact]
public async Task All_acked_when_SendEvent_succeeds()
{
@@ -149,9 +149,8 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
/// <see cref="GatewayAlarmHistorianWriter"/>, then <c>ReadEvents</c> the recent window for that
/// source through the real <see cref="GatewayHistorianDataSource"/> and assert the event is
/// present. Requires the gateway running <c>RuntimeDb:EventReadsEnabled=true</c> (the SQL
/// alarm-history read path). Presence is asserted as "at least one event for the source surfaced
/// in the post-send window" (the data source filters by source); the exact AlarmId / message
/// match is surfaced as a diagnostic, since the SQL event store may re-key the row.
/// alarm-history read path). The exact delivery GUID must return; an unrelated event for the
/// same source cannot satisfy the assertion.
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
@@ -196,7 +195,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
var read = await dataSource.ReadEventsAsync(
source, eventUtc - TimeSpan.FromMinutes(5), DateTime.UtcNow + TimeSpan.FromMinutes(1), maxEvents: 0, ct);
events = read.Events;
if (events.Count > 0) break;
if (events.Any(e => Guid.TryParse(e.EventId, out var id) && id == alarm.DeliveryId)) break;
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
while (DateTime.UtcNow < deadline);
@@ -211,18 +210,18 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
$"a source-filtered ReadEvents must return the just-sent alarm for source '{source}': the gateway now " +
"populates Source_Object from the event SourceName (C4 fix), so OtOpcUa's own alarm sends round-trip.");
var exactMatch = events.Any(e => string.Equals(e.EventId, alarmId, StringComparison.Ordinal));
var exactMatch = events.Any(e => Guid.TryParse(e.EventId, out var id) && id == alarm.DeliveryId);
exactMatch.ShouldBeTrue("the just-sent event must retain its nonzero delivery GUID through persistence and readback.");
TestContext.Current.SendDiagnosticMessage(
$"alarm round-trip: SendEvent source='{source}' id={alarmId} → ReadEvents returned {events.Count} event(s); exact-id match={exactMatch}.");
$"alarm round-trip: SendEvent source='{source}' delivery-id={alarm.DeliveryId:D} → exact ID returned among {events.Count} event(s).");
}
/// <summary>
/// Alarm send contract — the OtOpcUa responsibility in isolation: an <see cref="AlarmHistorianEvent"/>
/// maps to a wire event the gateway accepts and <c>SendEvent</c> returns <see cref="HistorianWriteOutcome.Ack"/>.
/// This is the half that must always hold (the readback half is historian-gated; see
/// <see cref="Alarm_SendEvent_then_ReadEvents"/>). Regression guard for the event-id mapping —
/// a client-supplied wire <c>Id</c> makes the gateway's SendEvent handler throw, so the mapper
/// must leave it unset.
/// <see cref="Alarm_SendEvent_then_ReadEvents"/>). The mapper sends the stable nonzero delivery GUID;
/// the earlier assumption that the gateway rejected every client-supplied ID was incorrect (#705).
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
@@ -251,7 +250,7 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
outcomes.ShouldHaveSingleItem().ShouldBe(
HistorianWriteOutcome.Ack,
"the alarm SendEvent must be acked (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id).");
"the alarm SendEvent carrying its nonzero delivery GUID must be acked.");
}
/// <summary>
@@ -7,6 +7,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
public sealed class AlarmEventMapperTests
{
[Fact]
public void A_direct_event_has_a_nonzero_wire_id_that_survives_repeated_mapping()
{
var alarm = new AlarmHistorianEvent("A", "S", "N", "LimitAlarm", AlarmSeverity.High,
"Activated", "m", "system", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var first = AlarmEventMapper.ToHistorianEvent(alarm).Id;
Assert.True(Guid.TryParse(first, out var id), $"Expected a delivery GUID; received '{first}'.");
Assert.NotEqual(Guid.Empty, id);
Assert.Equal(first, AlarmEventMapper.ToHistorianEvent(alarm).Id);
}
[Fact]
public void Maps_source_time_type_and_rich_properties()
{
@@ -17,9 +28,7 @@ public sealed class AlarmEventMapperTests
Assert.Equal("Area/Line/Pump1", e.SourceName);
Assert.Equal("LimitAlarm", e.Type);
Assert.Equal(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), e.EventTime.ToDateTime());
// The wire Id is deliberately left unset — the gateway's SendEvent rejects a client-supplied
// event id. The alarm's own id is carried as a property instead.
Assert.Equal(string.Empty, e.Id);
Assert.Equal(a.DeliveryId.ToString("D"), e.Id);
Assert.Equal("A1", e.Properties["AlarmId"]);
Assert.Equal("HiHi", e.Properties["AlarmName"]);
Assert.Equal("Activated", e.Properties["EventKind"]);
@@ -67,14 +76,21 @@ public sealed class AlarmEventMapperTests
}
[Fact]
public void Wire_id_is_never_set_and_blank_alarm_id_adds_no_property()
public void A_blank_condition_id_still_has_delivery_identity_and_adds_no_condition_property()
{
// A blank AlarmId must NOT fall back to a generated wire Id (the gateway rejects any id) and
// must not add an empty AlarmId property.
// Condition identity and event delivery identity serve different purposes.
var a = new AlarmHistorianEvent("", "S", "N", "DiscreteAlarm", AlarmSeverity.Low, "Cleared", "m", "system", null,
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var e = AlarmEventMapper.ToHistorianEvent(a);
Assert.Equal(string.Empty, e.Id);
Assert.Equal(a.DeliveryId.ToString("D"), e.Id);
Assert.False(e.Properties.ContainsKey("AlarmId"));
}
[Fact]
public void An_explicitly_empty_delivery_id_is_rejected_instead_of_reminted()
{
var alarm = new AlarmHistorianEvent("A", "S", "N", "LimitAlarm", AlarmSeverity.High,
"Activated", "m", "system", null, DateTime.UtcNow) { DeliveryId = Guid.Empty };
Assert.Throws<ArgumentException>(() => AlarmEventMapper.ToHistorianEvent(alarm));
}
}