using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests; /// /// The shared batched alarm write: one statement per chunk instead of one round trip per event, with the per-event /// outcomes the store-and-forward drain needs kept intact (a poison event is isolated, an outage retries everything, /// a cancellation never dead-letters). /// public sealed class AlarmBatchWriteTests { private sealed class PoisonException : Exception; private sealed class OutageException : Exception; private static HistorianWriteOutcome Classify(Exception ex) => ex is PoisonException ? HistorianWriteOutcome.PermanentFail : HistorianWriteOutcome.RetryPlease; private static HistorianWriteOutcome?[] Undecided(int n) => new HistorianWriteOutcome?[n]; [Fact] public async Task A_clean_batch_is_one_statement_per_chunk_and_all_Ack() { var statements = new List(); var r = await AlarmBatchWrite.RunAsync(Undecided(25), 10, (ix, _) => { statements.Add(ix.ToArray()); return Task.CompletedTask; }, Classify, null, CancellationToken.None); r.ShouldAllBe(o => o == HistorianWriteOutcome.Ack); statements.Select(s => s.Length).ShouldBe([10, 10, 5]); statements.SelectMany(s => s).ShouldBe(Enumerable.Range(0, 25)); } [Fact] public async Task A_poison_event_is_isolated_and_its_siblings_still_Ack() { var statements = 0; var r = await AlarmBatchWrite.RunAsync(Undecided(6), 10, (ix, _) => { statements++; if (ix.Contains(3)) throw new PoisonException(); return Task.CompletedTask; }, Classify, null, CancellationToken.None); r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack, HistorianWriteOutcome.PermanentFail, HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack]); statements.ShouldBe(1 + 6, "the failed chunk, then one statement per event"); } [Fact] public async Task An_outage_retries_the_whole_chunk_without_re_sending_event_by_event() { var statements = 0; var r = await AlarmBatchWrite.RunAsync(Undecided(6), 10, (_, _) => { statements++; throw new OutageException(); }, Classify, null, CancellationToken.None); r.ShouldAllBe(o => o == HistorianWriteOutcome.RetryPlease); statements.ShouldBe(1); } [Fact] public async Task Pre_decided_slots_are_kept_and_never_sent() { var sent = new List(); var outcomes = Undecided(4); outcomes[1] = HistorianWriteOutcome.PermanentFail; // e.g. a pre-1970 time on QuestDB var r = await AlarmBatchWrite.RunAsync(outcomes, 10, (ix, _) => { sent.AddRange(ix); return Task.CompletedTask; }, Classify, null, CancellationToken.None); r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.PermanentFail, HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack]); sent.ShouldBe([0, 2, 3]); } [Fact] public async Task A_cancellation_mid_batch_retries_everything_undecided_and_dead_letters_nothing() { using var cts = new CancellationTokenSource(); var r = await AlarmBatchWrite.RunAsync(Undecided(5), 2, (ix, ct) => { if (ix.Contains(2)) { cts.Cancel(); ct.ThrowIfCancellationRequested(); } return Task.CompletedTask; }, Classify, null, cts.Token); r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.Ack, HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.RetryPlease]); } [Fact] public async Task A_cancellation_during_poison_isolation_retries_the_rest() { using var cts = new CancellationTokenSource(); var r = await AlarmBatchWrite.RunAsync(Undecided(4), 10, (ix, ct) => { if (ix.Count > 1) throw new PoisonException(); if (ix[0] == 1) { cts.Cancel(); ct.ThrowIfCancellationRequested(); } return Task.CompletedTask; }, Classify, null, cts.Token); r.ShouldBe([HistorianWriteOutcome.Ack, HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.RetryPlease, HistorianWriteOutcome.RetryPlease]); } }