74afc3f90a
All three alarm-history writers inserted one event per round trip, serially (SqlServer also opened a pooled connection per event), so a 10000-event alarm storm drained at 10000 x RTT while the backlog sat in LocalDb. - Sql.Common.AlarmBatchWrite: one statement per chunk; a chunk that fails PermanentFail-class is re-sent event by event so only the poison event dead-letters; a transient failure retries the chunk; a cancellation never dead-letters. - SqlServer: multi-row insert-if-absent over a VALUES set (150 events per statement, under the 2100-parameter limit), duplicate ids in a batch written once; a duplicate-key race isolates per event, where it is Ack. No new table type, so no schema migration (the review suggested a TVP; the VALUES form gets the same one-round-trip-per-chunk without one). - QuestDb: multi-row INSERTs of 500 events; pre-1970 events still dead-letter unsent. - Gateway: no batch RPC exists, so sends are pipelined, at most 8 in flight, outcomes kept in input order. Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22 in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
103 lines
4.3 KiB
C#
103 lines
4.3 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<int[]>();
|
|
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<int>();
|
|
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]);
|
|
}
|
|
}
|