feat(historian-gateway): IHistorianProvisioning + GatewayTagProvisioner (EnsureTags, non-blocking)

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 17:30:03 -04:00
parent d3081a659f
commit 8559905e8a
3 changed files with 268 additions and 0 deletions
@@ -0,0 +1,81 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
/// <summary>
/// <see cref="IHistorianProvisioning"/> backed by the HistorianGateway <c>EnsureTags</c> path.
/// Non-historizable driver types are skipped (never built into a definition); the historizable
/// ones are mapped via <see cref="HistorianTypeMapper"/> and batched into a single
/// <c>EnsureTags</c> call.
/// </summary>
/// <remarks>
/// <b>Non-blocking.</b> A historian that is unreachable or errors must never fail an address-space
/// apply, so the gateway call is wrapped in a catch-all: any exception counts the whole sent batch
/// as <see cref="HistorianProvisionResult.Failed"/> and returns. The method never throws and never
/// logs tag values, hostnames, or credentials.
/// </remarks>
public sealed class GatewayTagProvisioner : IHistorianProvisioning
{
private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayTagProvisioner> _logger;
/// <summary>Creates the provisioner over a gateway client seam.</summary>
/// <param name="client">The gateway client used for the <c>EnsureTags</c> path.</param>
/// <param name="logger">Logger for skip/failure diagnostics (never logs tag values).</param>
public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger<GatewayTagProvisioner> logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<HistorianProvisionResult> EnsureTagsAsync(
IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(requests);
var definitions = new List<HistorianTagDefinition>(requests.Count);
var skipped = 0;
foreach (var request in requests)
{
if (!HistorianTypeMapper.IsHistorizable(request.DataType))
{
skipped++;
// Log only the (non-sensitive) data type — never the tag name.
_logger.LogDebug(
"Skipping provisioning of a non-historizable tag of type {DataType}.", request.DataType);
continue;
}
definitions.Add(new HistorianTagDefinition
{
TagName = request.TagName,
DataType = HistorianTypeMapper.ToHistorianDataType(request.DataType),
// Proto string fields are non-nullable — coalesce absent metadata to empty.
EngineeringUnit = request.EngineeringUnit ?? string.Empty,
Description = request.Description ?? string.Empty,
});
}
try
{
var results = await _client.EnsureTagsAsync(definitions, ct).ConfigureAwait(false);
var ensured = results.Results.Count(r => r.Success);
var failed = Math.Max(0, definitions.Count - ensured);
return new HistorianProvisionResult(requests.Count, ensured, skipped, failed);
}
catch (Exception exception)
{
// Non-blocking: a failed EnsureTags never fails the apply. Count the whole sent batch as
// Failed and return; log only the failure category (no tag values).
_logger.LogWarning(
"EnsureTags failed for {Count} historian tag(s) ({Exception}); provisioning deferred.",
definitions.Count, exception.GetType().Name);
return new HistorianProvisionResult(requests.Count, Ensured: 0, Skipped: skipped, Failed: definitions.Count);
}
}
}