feat(historian): config-gated SqliteStoreAndForward→Wonderware sink (AddAlarmHistorian)

This commit is contained in:
Joseph Doherty
2026-06-11 11:30:31 -04:00
parent e9355e9514
commit 943c621371
5 changed files with 186 additions and 0 deletions
@@ -22,7 +22,9 @@ using ZB.MOM.WW.OtOpcUa.Host.Health;
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.Wonderware.Client;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using ZB.MOM.WW.OtOpcUa.Runtime.Scripting;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
@@ -83,6 +85,18 @@ builder.Services.AddOtOpcUaCluster(builder.Configuration);
if (hasDriver)
{
builder.Services.AddOtOpcUaRuntime();
// 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 named-pipe 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.
builder.Services.AddAlarmHistorian(
builder.Configuration,
(opts, sp) => new WonderwareHistorianClient(
new WonderwareHistorianClientOptions(opts.PipeName, opts.SharedSecret),
sp.GetService<ILogger<WonderwareHistorianClient>>()));
// Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces
// the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor
// can materialise real IDriver instances on deploy.
@@ -10,5 +10,11 @@
"Auth": {
"DisableLogin": false
}
},
"AlarmHistorian": {
"Enabled": false,
"DatabasePath": "alarm-historian.db",
"PipeName": "OtOpcUaHistorian",
"SharedSecret": ""
}
}
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// <summary>
/// Binds the <c>AlarmHistorian</c> configuration section that gates the durable
/// store-and-forward alarm sink. When <see cref="Enabled"/> is <c>true</c>,
/// <c>AddAlarmHistorian</c> registers a <c>SqliteStoreAndForwardSink</c> (draining to the
/// Wonderware named-pipe writer supplied by the Host) in place of the
/// <c>NullAlarmHistorianSink</c> default; otherwise the Null default survives.
/// </summary>
public sealed class AlarmHistorianOptions
{
/// <summary>The configuration section name this options class binds.</summary>
public const string SectionName = "AlarmHistorian";
/// <summary>
/// When <c>true</c>, the durable SQLite store-and-forward sink is registered; when
/// <c>false</c> (the default) the no-op <c>NullAlarmHistorianSink</c> stays in place.
/// </summary>
public bool Enabled { get; init; }
/// <summary>Filesystem path to the local SQLite store-and-forward queue database.</summary>
public string DatabasePath { get; init; } = "alarm-historian.db";
/// <summary>Named-pipe name the Wonderware historian sidecar listens on.</summary>
public string PipeName { get; init; } = "OtOpcUaHistorian";
/// <summary>Per-process shared secret the sidecar verifies in the Hello frame.</summary>
public string SharedSecret { get; init; } = "";
/// <summary>Maximum number of queued rows the drain worker forwards in a single batch.</summary>
public int BatchSize { get; init; } = 100;
}
@@ -1,6 +1,7 @@
using Akka.Actor;
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
@@ -48,6 +49,45 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// Config-gated durable alarm-historian sink. When the <c>AlarmHistorian</c> section has
/// <c>Enabled=true</c>, registers a <see cref="SqliteStoreAndForwardSink"/> (draining via the
/// <paramref name="writerFactory"/>-supplied writer) as the <see cref="IAlarmHistorianSink"/>,
/// overriding the <see cref="NullAlarmHistorianSink"/> default. Otherwise a no-op (Null stays).
/// The writer is injected so the durable downstream (Wonderware named-pipe client) can be supplied
/// by the Host, which is the only project that references it.
/// </summary>
/// <param name="services">The service collection to register with.</param>
/// <param name="configuration">The configuration carrying the <c>AlarmHistorian</c> section.</param>
/// <param name="writerFactory">
/// Factory the Host supplies to build the concrete <see cref="IAlarmHistorianWriter"/>
/// (the Wonderware named-pipe client) from the bound options + the resolving provider.
/// </param>
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
public static IServiceCollection AddAlarmHistorian(
this IServiceCollection services,
IConfiguration configuration,
Func<AlarmHistorianOptions, IServiceProvider, IAlarmHistorianWriter> writerFactory)
{
var opts = configuration.GetSection(AlarmHistorianOptions.SectionName).Get<AlarmHistorianOptions>();
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
services.AddSingleton<IAlarmHistorianSink>(sp =>
{
// SqliteStoreAndForwardSink takes a Serilog ILogger (not Microsoft.Extensions.Logging).
// Resolve it off the host's configured static logger so the drain worker's WARN/INFO
// lines land in the same sinks as the rest of the process.
var sink = new SqliteStoreAndForwardSink(
opts.DatabasePath,
writerFactory(opts, sp),
Serilog.Log.Logger.ForContext<SqliteStoreAndForwardSink>(),
batchSize: opts.BatchSize);
sink.StartDrainLoop(TimeSpan.FromSeconds(5));
return sink;
});
return services;
}
/// <summary>
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>