feat(historian-gateway): ContinuousHistorizationRecorder actor (outbox->WriteLiveValues, backoff)

Continuous-historization engine for non-Galaxy driver tags. Registers
interest with the per-node DependencyMuxActor for the historized refs and
taps the VirtualTagActor.DependencyValueChanged values the mux fans:
coerce to numeric -> append to the durable IHistorizationOutbox (crash
boundary) -> off-thread drain writes batches through IHistorianValueWriter
and acks (FIFO-truncates) on success, backing off (exponential, capped) on
failure. Non-numeric values are dropped + metered (SQL analog path is
numeric-only).

- New seam IHistorianValueWriter + HistorizationValue in Core.Abstractions
  so Runtime stays free of the gRPC driver.
- GatewayHistorianValueWriter (driver) adapts IHistorianGatewayClient.
  WriteLiveValues: HistorizationValue -> HistorianLiveValue proto, WriteAck
  Success||Queued -> true; non-throwing (errors -> false for retry).
- Drain runs via PipeTo(Self) so the mailbox never blocks on the gateway
  write; appends awaited on the actor thread to stay serialized.

Adaptation vs plan: the mux fans DependencyValueChanged (TagId/Value/
TimestampUtc, no quality), not DriverInstanceActor.AttributeValuePublished,
so values are recorded Good-quality (192) by the same convention the
scripted-alarm host uses.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 18:18:34 -04:00
parent 8b4028de84
commit bbfbc7b215
4 changed files with 681 additions and 0 deletions
@@ -0,0 +1,89 @@
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
/// <summary>
/// Adapts the gateway client's <c>WriteLiveValues</c> RPC to the Runtime recorder's
/// <see cref="IHistorianValueWriter"/> seam. Maps each <see cref="HistorizationValue"/> onto a
/// proto <see cref="HistorianLiveValue"/> (numeric value + quality, with an optional timestamp —
/// a null timestamp leaves the proto field unset so the gateway's SQL writer server-stamps the
/// current time) and folds the returned <see cref="WriteAck"/> to a single retry/ack boolean.
/// </summary>
/// <remarks>
/// <para>
/// <b>Non-throwing by contract.</b> The recorder's drain loop stays simple by treating the
/// writer as never throwing: any gateway/transport error (and a non-success, non-queued ack)
/// is mapped to <c>false</c> so the recorder retains the outbox entries and retries. Only the
/// failure category (the exception type name) is logged — never tag values, hostnames, or
/// credentials.
/// </para>
/// <para>
/// A success ack OR a store-forward-queued ack maps to <c>true</c>: a value the gateway
/// durably queued must not be re-drained.
/// </para>
/// </remarks>
public sealed class GatewayHistorianValueWriter : IHistorianValueWriter
{
private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayHistorianValueWriter> _logger;
/// <summary>Creates the writer over a gateway client seam.</summary>
/// <param name="client">The gateway client used for the <c>WriteLiveValues</c> write path.</param>
/// <param name="logger">Logger for failure-category diagnostics (never logs value content).</param>
public GatewayHistorianValueWriter(IHistorianGatewayClient client, ILogger<GatewayHistorianValueWriter> logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<bool> WriteLiveValuesAsync(
string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrEmpty(tag);
ArgumentNullException.ThrowIfNull(values);
if (values.Count == 0)
{
// Nothing to write is a trivially-successful ack — the recorder treats it as drained.
return true;
}
try
{
var liveValues = new List<HistorianLiveValue>(values.Count);
foreach (HistorizationValue value in values)
{
var live = new HistorianLiveValue
{
NumericValue = value.Value,
Quality = value.Quality,
};
if (value.TimestampUtc is { } timestampUtc)
{
// Timestamp.FromDateTime requires Utc kind; coerce defensively. A null timestamp
// leaves the proto field unset -> the gateway's SQL writer server-stamps now.
live.Timestamp = Timestamp.FromDateTime(DateTime.SpecifyKind(timestampUtc, DateTimeKind.Utc));
}
liveValues.Add(live);
}
WriteAck ack = await _client.WriteLiveValuesAsync(tag, liveValues, ct).ConfigureAwait(false);
return ack.Success || ack.Queued;
}
catch (Exception exception)
{
// NEVER throw out of the writer — the recorder's drain expects a bool so its retain/retry
// logic stays simple. Log only the failure category (no value content, hostnames, or creds).
_logger.LogDebug(
"WriteLiveValues failed ({Exception}); recorder will retain and retry.",
exception.GetType().Name);
return false;
}
}
}