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:
+89
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user