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
102 lines
4.5 KiB
C#
102 lines
4.5 KiB
C#
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
|
|
/// <summary>
|
|
/// The gateway has no batch SendEvent RPC, so the alarm writer pipelines its per-event sends — at most
|
|
/// <see cref="GatewayAlarmHistorianWriter.MaxSendsInFlight"/> at once — instead of one round trip after another. Every
|
|
/// event still gets its own outcome, in input order.
|
|
/// </summary>
|
|
public sealed class GatewayAlarmWriterPipeliningTests
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private static AlarmHistorianEvent Evt(int i) => new(
|
|
$"alarm-{i}", "Area/Pump", "N", "LimitAlarm", AlarmSeverity.High, "Activated", $"m{i}", "u", null,
|
|
new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(i));
|
|
|
|
[Fact]
|
|
public async Task Sends_overlap_up_to_the_bound_and_outcomes_keep_input_order()
|
|
{
|
|
var inFlight = 0;
|
|
var maxInFlight = 0;
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
SendEventSource = async (e, ct) =>
|
|
{
|
|
var now = Interlocked.Increment(ref inFlight);
|
|
InterlockedMax(ref maxInFlight, now);
|
|
await Task.Delay(20, ct);
|
|
Interlocked.Decrement(ref inFlight);
|
|
// Odd messages are soft failures: their outcome must land on their own slot.
|
|
return e.Properties.TryGetValue("Message", out var m) && int.Parse(m[1..]) % 2 == 1
|
|
? new WriteAck { Success = false }
|
|
: new WriteAck { Success = true };
|
|
},
|
|
};
|
|
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
|
|
|
|
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, 40).Select(Evt).ToList(), Ct);
|
|
|
|
outcomes.Count.ShouldBe(40);
|
|
for (var i = 0; i < 40; i++)
|
|
outcomes[i].ShouldBe(i % 2 == 1 ? HistorianWriteOutcome.RetryPlease : HistorianWriteOutcome.Ack, $"event {i}");
|
|
maxInFlight.ShouldBeGreaterThan(1, "the sends must overlap — a sequential drain is 40 round trips");
|
|
maxInFlight.ShouldBeLessThanOrEqualTo(GatewayAlarmHistorianWriter.MaxSendsInFlight);
|
|
fake.SendEventCallCount.ShouldBe(40);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_cancellation_mid_batch_sends_nothing_more_and_dead_letters_nothing()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
var sent = 0;
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
SendEventSource = async (_, ct) =>
|
|
{
|
|
if (Interlocked.Increment(ref sent) == 3) await cts.CancelAsync();
|
|
await Task.Delay(10, ct);
|
|
return new WriteAck { Success = true };
|
|
},
|
|
};
|
|
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
|
|
|
|
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, 40).Select(Evt).ToList(), cts.Token);
|
|
|
|
outcomes.Count.ShouldBe(40);
|
|
outcomes.ShouldNotContain(HistorianWriteOutcome.PermanentFail);
|
|
outcomes.Count(o => o == HistorianWriteOutcome.RetryPlease).ShouldBeGreaterThan(0);
|
|
sent.ShouldBeLessThanOrEqualTo(GatewayAlarmHistorianWriter.MaxSendsInFlight + 2, "no new send starts after the cancel");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_poison_event_dead_letters_alone()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
SendEventSource = (e, _) => e.Properties.TryGetValue("Message", out var m) && m == "m5"
|
|
? Task.FromException<WriteAck>(new RpcException(new Status(StatusCode.InvalidArgument, "bad")))
|
|
: Task.FromResult(new WriteAck { Success = true }),
|
|
};
|
|
using var writer = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
|
|
|
|
var outcomes = await writer.WriteBatchAsync(Enumerable.Range(0, 10).Select(Evt).ToList(), Ct);
|
|
|
|
outcomes[5].ShouldBe(HistorianWriteOutcome.PermanentFail);
|
|
outcomes.Where((_, i) => i != 5).ShouldAllBe(o => o == HistorianWriteOutcome.Ack);
|
|
}
|
|
|
|
private static void InterlockedMax(ref int target, int value)
|
|
{
|
|
int current;
|
|
while ((current = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, current) != current) { }
|
|
}
|
|
}
|