From 8559905e8aa55eb6bb0c179e94c29b90840289db Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 17:30:03 -0400 Subject: [PATCH] feat(historian-gateway): IHistorianProvisioning + GatewayTagProvisioner (EnsureTags, non-blocking) Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Historian/IHistorianProvisioning.cs | 73 +++++++++++ .../GatewayTagProvisioner.cs | 81 +++++++++++++ .../GatewayTagProvisionerTests.cs | 114 ++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs new file mode 100644 index 00000000..a767cb5d --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs @@ -0,0 +1,73 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Server-side historian tag provisioning — ensures the historian knows about the tags the +/// address space historizes before values are written. Registered alongside +/// and invoked by the address-space applier when historized +/// nodes are (re)applied. +/// +/// +/// Provisioning is best-effort and non-blocking: an unreachable or erroring historian +/// never fails an address-space apply. Implementations return a +/// tally instead of throwing, so the applier can surface +/// a count without taking the server down. Non-historizable types are skipped (counted in +/// ), not failed. +/// +public interface IHistorianProvisioning +{ + /// + /// Ensures the supplied historian tags exist (create-or-update). Never throws; a transport or + /// backend failure is reported via . + /// + /// The tags to ensure, with their driver data type and optional metadata. + /// A cancellation token for the operation. + /// A tally of how the requests were handled. + Task EnsureTagsAsync( + IReadOnlyList requests, CancellationToken ct); +} + +/// +/// A single historian tag to ensure — the driver-agnostic shape the applier hands to +/// . A backend maps +/// onto its native tag type and skips types it cannot historize. +/// +/// The full reference / tag name to ensure in the historian. +/// The driver-agnostic data type, used to select the historian tag type. +/// Optional engineering unit (e.g. degC); null when unknown. +/// Optional human-readable description; null when unknown. +public sealed record HistorianTagProvisionRequest( + string TagName, + DriverDataType DataType, + string? EngineeringUnit, + string? Description); + +/// +/// The tally returned by . The buckets +/// partition the input: Requested == Ensured + Skipped + Failed. +/// +/// Total tags submitted. +/// Tags the historian acknowledged as created or already present. +/// Tags whose data type is not historizable on the backend (never sent). +/// Tags that were sent but the backend did not acknowledge (incl. a swallowed transport error). +public sealed record HistorianProvisionResult( + int Requested, + int Ensured, + int Skipped, + int Failed); + +/// +/// No-op — the applier's safe default when no historian +/// backend is registered. Every call returns an all-zero tally and never touches a backend. +/// +public sealed class NullHistorianProvisioning : IHistorianProvisioning +{ + /// The shared singleton instance. + public static readonly NullHistorianProvisioning Instance = new(); + + private NullHistorianProvisioning() { } + + /// + public Task EnsureTagsAsync( + IReadOnlyList requests, CancellationToken ct) => + Task.FromResult(new HistorianProvisionResult(0, 0, 0, 0)); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs new file mode 100644 index 00000000..ebba67f2 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs @@ -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; + +/// +/// backed by the HistorianGateway EnsureTags path. +/// Non-historizable driver types are skipped (never built into a definition); the historizable +/// ones are mapped via and batched into a single +/// EnsureTags call. +/// +/// +/// Non-blocking. 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 and returns. The method never throws and never +/// logs tag values, hostnames, or credentials. +/// +public sealed class GatewayTagProvisioner : IHistorianProvisioning +{ + private readonly IHistorianGatewayClient _client; + private readonly ILogger _logger; + + /// Creates the provisioner over a gateway client seam. + /// The gateway client used for the EnsureTags path. + /// Logger for skip/failure diagnostics (never logs tag values). + public GatewayTagProvisioner(IHistorianGatewayClient client, ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task EnsureTagsAsync( + IReadOnlyList requests, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(requests); + + var definitions = new List(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); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs new file mode 100644 index 00000000..acdecee9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +public sealed class GatewayTagProvisionerTests +{ + private static GatewayTagProvisioner Provisioner(FakeHistorianGatewayClient fake) => + new(fake, NullLogger.Instance); + + [Fact] + public async Task Ensures_numeric_tags_with_mapped_type() + { + var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() }; + var p = Provisioner(fake); + var reqs = new[] + { + new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, "degC", "Temp"), + new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null), + }; + + var result = await p.EnsureTagsAsync(reqs, TestContext.Current.CancellationToken); + + Assert.NotNull(fake.LastEnsureDefinitions); + Assert.Equal(2, fake.LastEnsureDefinitions!.Count); + Assert.Equal(HistorianDataType.Float, fake.LastEnsureDefinitions[0].DataType); + Assert.Equal(HistorianDataType.Int1, fake.LastEnsureDefinitions[1].DataType); + Assert.Equal(2, result.Requested); + Assert.Equal(0, result.Skipped); + } + + [Fact] + public async Task Maps_metadata_and_coalesces_null_metadata_to_empty() + { + var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() }; + var p = Provisioner(fake); + + await p.EnsureTagsAsync( + new[] + { + new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, "degC", "Temp"), + new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null), + }, + TestContext.Current.CancellationToken); + + var defs = fake.LastEnsureDefinitions!; + Assert.Equal("Pump1.Temp", defs[0].TagName); + Assert.Equal("degC", defs[0].EngineeringUnit); + Assert.Equal("Temp", defs[0].Description); + // Proto string fields are non-nullable — null metadata must coalesce to empty. + Assert.Equal(string.Empty, defs[1].EngineeringUnit); + Assert.Equal(string.Empty, defs[1].Description); + } + + [Fact] + public async Task Deferred_types_are_skipped_not_sent() + { + var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() }; + var p = Provisioner(fake); + + var result = await p.EnsureTagsAsync( + new[] { new HistorianTagProvisionRequest("Pump1.Name", DriverDataType.String, null, null) }, + TestContext.Current.CancellationToken); + + Assert.Empty(fake.LastEnsureDefinitions!); // String is deferred → never built into a definition + Assert.Equal(1, result.Requested); + Assert.Equal(1, result.Skipped); + } + + [Fact] + public async Task Gateway_failure_is_swallowed_and_counted_not_thrown() + { + var fake = new FakeHistorianGatewayClient { EnsureTagsThrows = new Exception("boom") }; + var p = Provisioner(fake); + + var result = await p.EnsureTagsAsync( + new[] { new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null) }, + TestContext.Current.CancellationToken); + + Assert.Equal(1, result.Failed); // non-blocking: no throw + } + + [Fact] + public async Task Ensured_count_reflects_successful_results() + { + var fake = new FakeHistorianGatewayClient + { + EnsureTagsResult = new TagOperationResults + { + Results = + { + new TagOperationResult { Name = "Pump1.Temp", Success = true }, + new TagOperationResult { Name = "Pump1.Run", Success = false, Error = "x" }, + }, + }, + }; + var p = Provisioner(fake); + + var result = await p.EnsureTagsAsync( + new[] + { + new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null), + new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null), + }, + TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Requested); + Assert.Equal(1, result.Ensured); + Assert.Equal(0, result.Skipped); + Assert.Equal(1, result.Failed); + } +}