test(historian): gateway writer acks an all-non-numeric batch without a call; fix stale writer docs

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 14:24:45 -04:00
parent 96ec751a0b
commit c6d4134795
2 changed files with 28 additions and 10 deletions
@@ -1,13 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// One numeric sample the continuous-historization recorder drains to the historian's SQL
/// live-value write path: an optional UTC timestamp, the typed value, and the OPC UA status code
/// the sample was recorded with.
/// One sample the continuous-historization recorder drains to the backend's live-value write path: an
/// optional UTC timestamp, the typed value (any <see cref="HistorianValueKind"/> the backend supports — the
/// Gateway backend takes numeric kinds only, SqlServer takes all of them), and the OPC UA status code the
/// sample was recorded with.
/// </summary>
/// <param name="TimestampUtc">
/// UTC source timestamp of the sample, or <c>null</c> to defer to the historian's server-stamped
/// current-time path (the SQL writer uses <c>SYSDATETIME()</c> when the timestamp is absent).
/// UTC source timestamp of the sample, or <c>null</c> to have the backend stamp the current time.
/// </param>
/// <param name="Value">The typed sample value.</param>
/// <param name="StatusCode">OPC UA status code of the sample (0 = Good).</param>
@@ -16,20 +16,20 @@ public readonly record struct HistorizationValue(DateTime? TimestampUtc, Histori
/// <summary>
/// Seam over the historian's live-value write path used by the continuous-historization recorder.
/// Lives in the abstraction layer so the Runtime recorder depends on it without taking a hard
/// reference on the gRPC gateway driver; the gateway driver supplies the concrete adapter
/// (<c>GatewayHistorianValueWriter</c>).
/// reference on a backend driver; the selected backend provider supplies the concrete writer
/// (<c>GatewayHistorianValueWriter</c> or <c>SqlServerHistorianValueWriter</c>).
/// </summary>
public interface IHistorianValueWriter
{
/// <summary>
/// Writes a batch of live values for a single tag through the historian's SQL live-write path.
/// Writes a batch of live values for a single tag through the backend's live-write path.
/// Implementations are expected to be non-throwing: a transport/gateway error is surfaced as a
/// <c>false</c> result so the recorder retains the entries and retries, rather than as an
/// exception.
/// </summary>
/// <param name="tag">Fully-qualified historian tag the values are recorded against.</param>
/// <param name="values">The numeric samples to write, in append order.</param>
/// <param name="values">The samples to write, in append order.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns><c>true</c> on a successful (or durably-queued) gateway ack; <c>false</c> on a retryable failure.</returns>
/// <returns><c>true</c> when the backend stored (or durably queued) the batch; <c>false</c> on a retryable failure.</returns>
Task<bool> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct);
}
@@ -73,6 +73,24 @@ public sealed class GatewayHistorianValueWriterTests
Assert.False(ok);
}
[Fact]
public async Task All_non_numeric_batch_is_acked_without_calling_the_gateway()
{
// A soft-failure ack: had the writer called the gateway, the result would be false.
var fake = new FakeHistorianGatewayClient { WriteLiveValuesResult = new WriteAck { Success = false, Queued = false } };
var values = new[]
{
new HistorizationValue(DateTime.UtcNow, HistorianValue.FromString("text"), 0u),
new HistorizationValue(DateTime.UtcNow, HistorianValue.FromDateTime(DateTime.UtcNow), 0u),
new HistorizationValue(DateTime.UtcNow, HistorianValue.Empty, 0x80310000u),
};
var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken);
Assert.True(ok);
Assert.Equal(0, fake.WriteLiveValuesCallCount);
}
[Fact]
public async Task Empty_batch_is_trivially_acked_without_calling_the_gateway()
{