Compare commits

...

2 Commits

Author SHA1 Message Date
Joseph Doherty 109163621c fix(historian): preserve stable alarm delivery identities
v2-ci / build (pull_request) Successful in 5m52s
v2-ci / unit-tests (pull_request) Failing after 1h23m55s
2026-10-13 07:10:58 -04:00
dohertj2 0ccfbbee5c Merge pull request 'Accept supported definitive loss reasons in restart wire test' (#704) from fix/restart-loss-reason into master
v2-ci / build (push) Successful in 12m15s
v2-ci / unit-tests (push) Failing after 35m1s
2026-10-13 06:30:22 -04:00
13 changed files with 341 additions and 27 deletions
+13
View File
@@ -153,6 +153,19 @@ Ordering follows from that: the drain reads in `enqueued_at_utc` order (with `id
as a tiebreak so the ordering is total), because a hashed key carries no
insertion sequence.
Gateway delivery carries a separate nonzero `DeliveryId` GUID. Direct event instances
create it once; retries and record copies preserve it. It is excluded from queue JSON
and business equality, so the existing payload fingerprint and pair deduplication stay
unchanged. The drain derives its delivery GUID solely from the persisted row `id`,
including migrated legacy rows. It never hashes a reconstructed payload or mints an ID
per send. The versioned encoding in `AlarmDeliveryIds` therefore survives retry,
restart, payload edits and replicated failover without a schema migration.
Different business payloads at the same timestamp remain distinct; completely identical
payloads deliberately converge. An explicitly empty direct delivery ID is rejected.
The Gateway mapper sends this GUID as the wire event ID; the live alarm readback test
requires that exact ID. SQL Server and QuestDB retain their existing event-ID schemes.
`EnqueueAsync` does a single `INSERT … ON CONFLICT DO NOTHING` on the hot path.
To avoid a `SELECT COUNT(*)` on every enqueue, the sink keeps an in-memory
non-dead-lettered row counter (seeded at startup, kept current by every mutation,
@@ -0,0 +1,9 @@
# Alarm delivery identity results — lmxopcua #705
Alarm events now carry a nonzero delivery GUID created once for direct delivery and preserved across retries and record copies. It is excluded from queue JSON and business-field equality/hash. Queued delivery overrides it with a frozen versioned UUID encoding derived only from the persisted queue row key, preserving existing payload JSON, fingerprints, schema, deduplication and AlarmId. The Gateway mapper sends that GUID and rejects explicitly empty identities. SQL Server and QuestDB identity logic is unchanged.
Coverage includes literal historical JSON/fingerprint/GUID vectors; ten-field equality and hash behavior; direct copies/retries; queue retry/reopen/payload edits/dead-letter revival; identical-input convergence; distinct same-time business events; real replicated queue convergence; and legacy migration. Reconstructed direct objects receive new identities; durable reconstruction uses the queue. Completely identical business payloads remain intentionally deduplicated.
TDD: direct wire regression failed on an empty ID; queued retry/reopen regression failed on changing IDs. Final affected runs: Core51/51, Host migration/convergence16/16, Gateway453passed/6live skipped/0failed. Full offline solution:14,635passed/611skipped/0failed across62suites, exit0. Independent final review and diffcheck clear. Fixture configuration variables were removed before all runs.
No live event writes, deployed sender rollout or historical row rewrite occurred. The live round-trip test now requires exact delivery GUID readback; actual persistence remains the open Gateway #28 gate, alongside its separate #39 timestamp-offset provider failure. Upstream History controls #29 and lmxopcua #694/#700 remain open.
@@ -0,0 +1,25 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
/// <summary>Stable delivery GUIDs for persisted alarm queue rows.</summary>
public static class AlarmDeliveryIds
{
/// <summary>
/// Converts an existing durable row key into a nonzero version-8 GUID. Version 1 of this encoding hashes
/// UTF-8 "OtOpcUa.AlarmDelivery/v1\0" followed by the exact persisted key with SHA-256, takes the first
/// 16 bytes, sets the UUID version/variant bits, and interprets the bytes in network order.
/// This encoding must remain stable across retries, upgrades and replicated failover.
/// </summary>
/// <param name="queueId">The persisted row key, never a hash recomputed from the current payload.</param>
/// <returns>The row's stable delivery identity.</returns>
public static Guid FromQueueId(string queueId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(queueId);
var digest = SHA256.HashData(Encoding.UTF8.GetBytes("OtOpcUa.AlarmDelivery/v1\0" + queueId));
digest[6] = (byte)((digest[6] & 0x0F) | 0x80);
digest[8] = (byte)((digest[8] & 0x3F) | 0x80);
return new Guid(digest.AsSpan(0, 16), bigEndian: true);
}
}
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
@@ -34,4 +35,31 @@ public sealed record AlarmHistorianEvent(
string Message,
string User,
string? Comment,
DateTime TimestampUtc);
DateTime TimestampUtc)
{
/// <summary>
/// Delivery identity, minted once for direct sends and replaced by the durable queue's identity when drained.
/// Excluded from payload serialization and business equality so redundant enqueues still converge.
/// A record copy preserves this identity; an explicitly empty identity is invalid for delivery.
/// </summary>
[JsonIgnore]
public Guid DeliveryId { get; init; } = Guid.NewGuid();
/// <summary>Compares the ten business fields, excluding transport delivery identity.</summary>
/// <param name="other">The event to compare.</param>
/// <returns>Whether the business fields are equal.</returns>
public bool Equals(AlarmHistorianEvent? other) => ReferenceEquals(this, other) || other is not null
&& AlarmId == other.AlarmId && EquipmentPath == other.EquipmentPath && AlarmName == other.AlarmName
&& AlarmTypeName == other.AlarmTypeName && Severity == other.Severity && EventKind == other.EventKind
&& Message == other.Message && User == other.User && Comment == other.Comment && TimestampUtc == other.TimestampUtc;
/// <summary>Hashes the same business fields used by equality, excluding delivery identity.</summary>
/// <returns>The business-field hash code.</returns>
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(AlarmId); hash.Add(EquipmentPath); hash.Add(AlarmName); hash.Add(AlarmTypeName); hash.Add(Severity);
hash.Add(EventKind); hash.Add(Message); hash.Add(User); hash.Add(Comment); hash.Add(TimestampUtc);
return hash.ToHashCode();
}
}
@@ -603,6 +603,9 @@ public sealed class LocalDbStoreAndForwardSink : IAlarmHistorianSink, IDisposabl
try
{
evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
// The durable key survives retry, payload edits, legacy migration and replicated failover.
// Never derive delivery identity from a reserialized payload or an individual RPC attempt.
if (evt is not null) evt = evt with { DeliveryId = AlarmDeliveryIds.FromQueueId(id) };
}
catch (JsonException)
{
@@ -35,16 +35,16 @@ internal static class AlarmEventMapper
/// <returns>The gateway wire event ready for <c>SendEvent</c>.</returns>
public static HistorianEvent ToHistorianEvent(AlarmHistorianEvent alarm)
{
if (alarm.DeliveryId == Guid.Empty)
throw new ArgumentException("Alarm delivery identity must be nonzero.", nameof(alarm));
// Timestamp.FromDateTime requires a Utc-kind DateTime; coerce defensively (TimestampUtc is
// already Utc by contract, but a caller could pass Unspecified).
var eventTime = Timestamp.FromDateTime(DateTime.SpecifyKind(alarm.TimestampUtc, DateTimeKind.Utc));
var historianEvent = new HistorianEvent
{
// Deliberately DO NOT set HistorianEvent.Id: the gateway's SendEvent path rejects a
// client-supplied event id (the server handler throws and the call fails permanently —
// confirmed live). The historian assigns event identity server-side; the alarm's own id
// is preserved below as a property for read-back correlation/traceability.
// Identity belongs to the event/durable queue, never to an individual RPC attempt.
Id = alarm.DeliveryId.ToString("D"),
SourceName = alarm.EquipmentPath,
Type = alarm.AlarmTypeName,
EventTime = eventTime,
@@ -0,0 +1,78 @@
using System.Text.Json;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
public sealed class AlarmHistorianEventIdentityTests
{
internal const string Payload = """{"AlarmId":"A","EquipmentPath":"S","AlarmName":"N","AlarmTypeName":"LimitAlarm","Severity":2,"EventKind":"Activated","Message":"m","User":"system","Comment":null,"TimestampUtc":"2026-01-01T00:00:00Z"}""";
internal const string QueueId = "52C8292271C629CCC2BFE4EC8C16A5E70CA7456C7D88BC282ABCEDF636F05901";
private static AlarmHistorianEvent Event() => new("A", "S", "N", "LimitAlarm", AlarmSeverity.High,
"Activated", "m", "system", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
[Theory]
[InlineData(QueueId, "48bca092-b99d-8ea4-a2de-8b30d80a481a")]
[InlineData("persisted-row-v1", "39473040-002b-876c-a1e6-9a9ad4a60ded")]
public void Persisted_keys_have_frozen_versioned_delivery_GUID_vectors(string key, string expected)
{
Assert.Equal(Guid.Parse(expected), AlarmDeliveryIds.FromQueueId(key));
}
[Fact]
public void Delivery_identity_is_nonzero_and_a_copy_preserves_it()
{
var original = Event();
Assert.NotEqual(Guid.Empty, original.DeliveryId);
Assert.Equal(original.DeliveryId, (original with { }).DeliveryId);
Assert.Equal(original.DeliveryId, (original with { Comment = "copy" }).DeliveryId);
}
[Fact]
public void Business_equality_hash_and_historical_JSON_ignore_delivery_identity()
{
var first = Event();
var second = Event();
Assert.NotEqual(first.DeliveryId, second.DeliveryId);
Assert.Equal(first, second);
Assert.True(first == second);
Assert.Equal(first.GetHashCode(), second.GetHashCode());
Assert.Single(new HashSet<AlarmHistorianEvent> { first, second });
Assert.Equal(Payload, JsonSerializer.Serialize(first));
Assert.Equal(Payload, JsonSerializer.Serialize(second));
Assert.Equal(QueueId, AlarmSfSchema.DeriveId(Payload));
Assert.Equal(first, JsonSerializer.Deserialize<AlarmHistorianEvent>(Payload));
}
[Theory]
[InlineData("AlarmId")]
[InlineData("EquipmentPath")]
[InlineData("AlarmName")]
[InlineData("AlarmTypeName")]
[InlineData("Severity")]
[InlineData("EventKind")]
[InlineData("Message")]
[InlineData("User")]
[InlineData("Comment")]
[InlineData("TimestampUtc")]
public void Each_business_field_still_participates_in_equality(string field)
{
var first = Event();
var changed = field switch
{
"AlarmId" => first with { AlarmId = "B" },
"EquipmentPath" => first with { EquipmentPath = "T" },
"AlarmName" => first with { AlarmName = "Other" },
"AlarmTypeName" => first with { AlarmTypeName = "DiscreteAlarm" },
"Severity" => first with { Severity = AlarmSeverity.Low },
"EventKind" => first with { EventKind = "Cleared" },
"Message" => first with { Message = "other" },
"User" => first with { User = "operator" },
"Comment" => first with { Comment = "note" },
"TimestampUtc" => first with { TimestampUtc = first.TimestampUtc.AddTicks(1) },
_ => throw new ArgumentOutOfRangeException(nameof(field)),
};
Assert.NotEqual(first, changed);
Assert.False(first == changed);
}
}
@@ -123,6 +123,69 @@ public sealed class LocalDbStoreAndForwardSinkTests : IDisposable
Comment: null,
TimestampUtc: ts ?? DateTime.UtcNow);
[Fact]
public async Task Queued_delivery_identity_survives_retry_reopen_and_payload_changes()
{
// The persisted row key is authoritative: reserializing today's payload must never change a retry's identity.
var expected = Guid.Parse("48bca092-b99d-8ea4-a2de-8b30d80a481a");
var evt = System.Text.Json.JsonSerializer.Deserialize<AlarmHistorianEvent>(AlarmHistorianEventIdentityTests.Payload)!;
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
using (var sink = new LocalDbStoreAndForwardSink(Db, writer, _log))
{
await sink.EnqueueAsync(evt, TestContext.Current.CancellationToken);
var row = (await Db.QueryAsync("SELECT id, payload_json FROM alarm_sf_events",
r => (Id: r.GetString(0), Payload: r.GetString(1)), null, TestContext.Current.CancellationToken)).Single();
row.Id.ShouldBe(AlarmHistorianEventIdentityTests.QueueId);
row.Payload.ShouldBe(AlarmHistorianEventIdentityTests.Payload);
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
writer.Batches.ShouldAllBe(batch => batch.Single().DeliveryId == expected);
await Db.ExecuteAsync("UPDATE alarm_sf_events SET payload_json = @Payload",
new { Payload = System.Text.Json.JsonSerializer.Serialize(evt with { Comment = "edited" }) },
TestContext.Current.CancellationToken);
}
_provider!.Dispose();
_provider = null;
_db = null;
var reopenedWriter = new FakeWriter();
using var reopened = new LocalDbStoreAndForwardSink(Db, reopenedWriter, _log);
await reopened.DrainOnceAsync(TestContext.Current.CancellationToken);
reopenedWriter.Batches.Single().Single().DeliveryId.ShouldBe(expected);
reopenedWriter.Batches.Single().Single().Comment.ShouldBe("edited");
reopened.GetStatus().QueueDepth.ShouldBe(0);
}
[Fact]
public async Task Independently_created_identical_events_keep_the_historical_queue_key()
{
var first = System.Text.Json.JsonSerializer.Deserialize<AlarmHistorianEvent>(AlarmHistorianEventIdentityTests.Payload)!;
var second = System.Text.Json.JsonSerializer.Deserialize<AlarmHistorianEvent>(AlarmHistorianEventIdentityTests.Payload)!;
first.DeliveryId.ShouldNotBe(second.DeliveryId);
using var sink = new LocalDbStoreAndForwardSink(Db, new FakeWriter(), _log);
await sink.EnqueueAsync(first, TestContext.Current.CancellationToken);
await sink.EnqueueAsync(second, TestContext.Current.CancellationToken);
sink.GetStatus().QueueDepth.ShouldBe(1);
(await Db.QueryAsync("SELECT id FROM alarm_sf_events", r => r.GetString(0), null, TestContext.Current.CancellationToken))
.ShouldBe([AlarmHistorianEventIdentityTests.QueueId]);
}
[Fact]
public async Task Distinct_business_events_at_the_same_time_have_distinct_queued_delivery_ids()
{
var first = Event("A", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
var second = first with { EventKind = "Cleared", Comment = "different transition" };
var writer = new FakeWriter();
using var sink = new LocalDbStoreAndForwardSink(Db, writer, _log);
await sink.EnqueueAsync(first, TestContext.Current.CancellationToken);
await sink.EnqueueAsync(second, TestContext.Current.CancellationToken);
sink.GetStatus().QueueDepth.ShouldBe(2);
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
var delivered = writer.Batches.Single();
delivered.Count.ShouldBe(2);
delivered.ShouldAllBe(evt => evt.DeliveryId != Guid.Empty);
delivered.Select(evt => evt.DeliveryId).Distinct().Count().ShouldBe(2);
}
/// <summary>Verifies that acknowledged events are removed from the queue.</summary>
[Fact]
public async Task EnqueueThenDrain_Ack_removes_row()
@@ -297,6 +360,11 @@ public sealed class LocalDbStoreAndForwardSinkTests : IDisposable
var status = sink.GetStatus();
status.QueueDepth.ShouldBe(1);
status.DeadLetterDepth.ShouldBe(0);
var originalDeliveryId = writer.Batches.Single().Single().DeliveryId;
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
writer.Batches.Count.ShouldBe(2);
writer.Batches.Last().Single().DeliveryId.ShouldBe(originalDeliveryId);
sink.GetStatus().QueueDepth.ShouldBe(0);
}
/// <summary>Verifies that the exponential backoff ladder caps at 60 seconds.</summary>
@@ -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));
}
}
@@ -41,10 +41,14 @@ public sealed class AlarmSfConvergenceTests
/// <summary>A writer that never acknowledges — a historian outage, which is when the buffer matters.</summary>
private sealed class OutageWriter : IAlarmHistorianWriter
{
public List<AlarmHistorianEvent> Events { get; } = [];
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
Events.AddRange(batch);
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(
batch.Select(_ => HistorianWriteOutcome.RetryPlease).ToList());
}
}
private static AlarmHistorianEvent Event(string alarmId, int second) => new(
@@ -170,11 +174,15 @@ public sealed class AlarmSfConvergenceTests
await pair.StartAsync();
var shared = Event("eq/shared", 1);
using var sinkA = new LocalDbStoreAndForwardSink(pair.A, new OutageWriter(), Log);
using var sinkB = new LocalDbStoreAndForwardSink(pair.B, new OutageWriter(), Log);
var sharedOnB = Event("eq/shared", 1);
shared.DeliveryId.ShouldNotBe(sharedOnB.DeliveryId);
var writerA = new OutageWriter();
var writerB = new OutageWriter();
using var sinkA = new LocalDbStoreAndForwardSink(pair.A, writerA, Log);
using var sinkB = new LocalDbStoreAndForwardSink(pair.B, writerB, Log);
await sinkA.EnqueueAsync(shared, TestContext.Current.CancellationToken);
await sinkB.EnqueueAsync(shared, TestContext.Current.CancellationToken);
await sinkB.EnqueueAsync(sharedOnB, TestContext.Current.CancellationToken);
// A second, DISTINCT event on B. Without it the scenario would pass with replication
// switched off entirely — each node would hold its own single copy and "count == 1 on
@@ -193,5 +201,13 @@ public sealed class AlarmSfConvergenceTests
async () => await CountAsync(pair.A) > 2 || await CountAsync(pair.B) > 2,
TimeSpan.FromSeconds(3)))
.ShouldBeFalse("the duplicate must stay collapsed");
// Either node can retry after promotion, with the same GUID despite independent input identities.
await sinkA.DrainOnceAsync(TestContext.Current.CancellationToken);
await sinkB.DrainOnceAsync(TestContext.Current.CancellationToken);
var deliveryA = writerA.Events.Single(e => e.AlarmId == "eq/shared").DeliveryId;
var deliveryB = writerB.Events.Single(e => e.AlarmId == "eq/shared").DeliveryId;
deliveryA.ShouldNotBe(Guid.Empty);
deliveryB.ShouldBe(deliveryA);
}
}
@@ -36,6 +36,38 @@ public sealed class AlarmSfLegacyMigratorTests : IDisposable
private ServiceProvider? _provider;
private sealed class CapturingRetryWriter : IAlarmHistorianWriter
{
public List<AlarmHistorianEvent> Events { get; } = [];
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
{
Events.AddRange(batch);
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(batch.Select(_ => HistorianWriteOutcome.RetryPlease).ToArray());
}
}
[Fact]
public async Task A_legacy_payload_keeps_its_key_and_stable_delivery_identity_across_drains()
{
var payload = Payload("eq/a");
SeedLegacy((1, "eq/a", payload, 0, dead: 0));
var db = BuildDb();
AlarmSfLegacyMigrator.Migrate(db, ConfigWithLegacyPath());
var row = (await db.QueryAsync("SELECT id, payload_json FROM alarm_sf_events",
r => (Id: r.GetString(0), Payload: r.GetString(1)), null, TestContext.Current.CancellationToken)).Single();
row.Id.ShouldBe("B1D80B7207C3F3B6CB31105DBF1A6E0D6FE300DA1B3F877AB746B36535310A60");
row.Payload.ShouldBe(payload);
var writer = new CapturingRetryWriter();
using var log = new Serilog.LoggerConfiguration().CreateLogger();
using (var sink = new LocalDbStoreAndForwardSink(db, writer, log))
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
using (var sink = new LocalDbStoreAndForwardSink(db, writer, log))
await sink.DrainOnceAsync(TestContext.Current.CancellationToken);
writer.Events.Count.ShouldBe(2);
writer.Events.ShouldAllBe(evt => evt.DeliveryId == Guid.Parse("55372883-eaec-8dd7-a1a4-6f98f416a272"));
}
// ---- the cases ---------------------------------------------------------------------------
[Fact]