From 0be79219fc17d4dc42838455be832d4f397c0c07 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 17:40:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(historian-gateway):=20alarm-write=20cutove?= =?UTF-8?q?r=20=E2=80=94=20AddAlarmHistorian=20drains=20to=20GatewayAlarmH?= =?UTF-8?q?istorianWriter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- ...wayHistorianServiceCollectionExtensions.cs | 49 +++++++++++++++++-- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 25 ++++++---- .../GatewayAlarmWriterFactoryTests.cs | 28 +++++++++++ 3 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs index b52fa217..4acd5b88 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs @@ -2,16 +2,17 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; /// -/// Host-callable factory that builds the gateway-backed server-side HistoryRead data source. The -/// Host's AddServerHistorian wiring supplies as its -/// Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource>, keeping the -/// concrete package-client dependency inside this driver project (the Host references only the -/// driver, not the package client directly). +/// Host-callable factories that build the gateway-backed historian seams against the single +/// ServerHistorian gateway: for the read path (the Host's +/// AddServerHistorian wiring) and for the alarm-write path +/// (the Host's AddAlarmHistorian wiring). Both keep the concrete package-client dependency +/// inside this driver project — the Host references only the driver, not the package client directly. /// public static class GatewayHistorian { @@ -39,4 +40,42 @@ public static class GatewayHistorian HistorianGatewayClientAdapter.Create(options, loggerFactory), logger); } + + /// + /// Builds a over a lazily connected + /// mapped from the bound + /// — the same single gateway the read path + /// () targets. The Host's AddAlarmHistorian wiring supplies + /// this as the concrete the durable + /// SqliteStoreAndForwardSink drain worker delegates to, sourcing the connection from the + /// ServerHistorian section (endpoint/key/TLS) rather than the legacy Wonderware-shaped + /// AlarmHistorian host/port. Resolves an and the writer's + /// from , falling back to the null + /// implementations when absent. Performs no network I/O — the underlying channel dials on first send. + /// + /// + /// This deliberately constructs its own — a + /// second gRPC channel to the same gateway as the read path. Collapsing the two onto one shared + /// channel would require the container to own a singleton and + /// the read-side to stop owning + disposing its client, + /// regressing the read cutover's dispose ownership (and its tests). A second channel to a co-located + /// sidecar is cheap — the gateway pools and amortizes the underlying historian sessions server-side — + /// so each path keeps its own channel with a clean, independent lifetime. + /// + /// The bound ServerHistorian configuration (endpoint, key, TLS posture). + /// The resolving service provider (used only to locate logging services). + /// The gateway-backed . + public static IAlarmHistorianWriter CreateAlarmWriter(ServerHistorianOptions options, IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(services); + + var loggerFactory = services.GetService() ?? NullLoggerFactory.Instance; + var logger = services.GetService>() + ?? NullLogger.Instance; + + return new GatewayAlarmHistorianWriter( + HistorianGatewayClientAdapter.Create(options, loggerFactory), + logger); + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 478dd454..14dd4575 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -23,7 +23,6 @@ using ZB.MOM.WW.OtOpcUa.Host.Logging; using ZB.MOM.WW.OtOpcUa.Host.Observability; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; -using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Historian; using ZB.MOM.WW.OtOpcUa.Runtime.Scripting; @@ -96,17 +95,23 @@ if (hasDriver) // Config-gated durable alarm-historian sink. When the AlarmHistorian section is enabled this // overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins) - // with a SqliteStoreAndForwardSink draining to the Wonderware TCP writer. The writer is - // injected here because the Host is the only project that references the Wonderware client — - // Runtime owns the gating + Sqlite construction, the Host supplies the concrete downstream. + // with a SqliteStoreAndForwardSink draining to the gateway SendEvent writer. The alarm-write path + // targets the SAME single gateway as the read path, so its connection (endpoint/key/TLS) is sourced + // from the ServerHistorian section — NOT the legacy Wonderware-shaped AlarmHistorian host/port. + // AlarmHistorianOptions still supplies the Enabled gate + the SQLite store-and-forward knobs + // (consumed inside AddAlarmHistorian), so its Wonderware connection fields are intentionally unused. + // Runtime owns the gating + Sqlite construction; the Host supplies the concrete gateway downstream + // via the driver factory (which owns the package-client adapter). The writer builds its OWN gateway + // channel — a second channel to the same sidecar: sharing one channel with the read path would force + // the read-side GatewayHistorianDataSource to stop owning + disposing its client (regressing the read + // cutover), and a second channel to a co-located sidecar is cheap (the gateway pools the historian + // sessions server-side). + var serverHistorianOptions = builder.Configuration + .GetSection(ServerHistorianOptions.SectionName).Get() + ?? new ServerHistorianOptions(); builder.Services.AddAlarmHistorian( builder.Configuration, - (opts, sp) => new WonderwareHistorianClient( - new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret) - { - UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint, - }, - sp.GetService>())); + (_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp)); // Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this // overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs new file mode 100644 index 00000000..ddbfc80a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmWriterFactoryTests.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// +/// Alarm-write cutover seam test (T13). The Host's AddAlarmHistorian wiring drains the durable +/// SqliteStoreAndForwardSink through this factory, so it must yield the gateway-backed writer — +/// sourcing the single gateway's connection from (endpoint/key/TLS), +/// not the legacy Wonderware-shaped AlarmHistorian host/port. Built offline: the underlying +/// channel dials lazily, so both the factory and the writer ctor perform no network I/O (a bogus, +/// unreachable endpoint must construct without throwing or connecting). +/// +public sealed class GatewayAlarmWriterFactoryTests +{ + [Fact] + public void Factory_builds_GatewayAlarmHistorianWriter() + { + var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" }; + using var services = new ServiceCollection().BuildServiceProvider(); + + IAlarmHistorianWriter writer = GatewayHistorian.CreateAlarmWriter(opts, services); + + Assert.IsType(writer); + } +}