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
@@ -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]