fix(historian): preserve stable alarm delivery identities
This commit is contained in:
+27
@@ -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()
|
||||
{
|
||||
|
||||
+9
-10
@@ -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>
|
||||
|
||||
+23
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user