fix(historian-gateway): wire dormant GatewayTagProvisioner + provisioning observability/docs
PR #423 shipped GatewayTagProvisioner + unit tests but never registered it in DI nor passed it into the AddressSpaceApplier, so deploying historized tags used the no-op NullHistorianProvisioning and never called the gateway's EnsureTags (confirmed live on wonder-app-vd03: zero EnsureTags calls on a historized deploy). Addresses HISTORIAN-GATEWAY-INTEGRATION-ISSUES.md. Issue 1 (wire provisioner): - Runtime: AddHistorianProvisioning extension (gated on ServerHistorian:Enabled, mirrors AddServerHistorian) + NullHistorianProvisioning TryAdd default in AddOtOpcUaRuntime; WithOtOpcUaRuntimeActors resolves IHistorianProvisioning and passes it into the applier. - Gateway driver: GatewayHistorian.CreateProvisioner factory (mirrors CreateDataSource). - Host: Program.cs calls AddHistorianProvisioning after AddServerHistorian. - Tests: AddHistorianProvisioningTests (config-gated registration + the register->resolve->applier->EnsureTags chain). Issue 2 (observability): AddressSpaceApplier logs the provisioning tally on every successful dispatch (was gated behind Failed/Skipped > 0), including dispatched=N so a dispatched=N/requested=0 line flags the dormant no-op. +2 tests. Issue 3 (30s HistoryRead on unprovisioned tags): root coupling fixed by Issue 1; documented the CallTimeout knob + coupling. Default left at 30s pending the multi-data-point investigation the issue requests (lowering risks truncating legitimate large reads). Issue 4 (docs): docs/Historian.md gains a "Tag auto-provisioning (EnsureTags)" section and CLAUDE.md a wiring/gating note (both stress ServerHistorian:Enabled). Sibling scadaproj/CLAUDE.md carries no false claim -> unchanged. Pre-existing Serilog observation: anchor CWD to AppContext.BaseDirectory before AddZbSerilog so the relative file sink stops landing in C:\Windows\System32 under the Windows-service CWD. Builds 0-error; Runtime.Tests 355, OpcUaServer.Tests 329, Gateway.Tests 99 (+4 live-skipped) all green.
This commit is contained in:
+36
@@ -78,4 +78,40 @@ public static class GatewayHistorian
|
||||
HistorianGatewayClientAdapter.Create(options, loggerFactory),
|
||||
logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="GatewayTagProvisioner"/> over a lazily connected
|
||||
/// <see cref="HistorianGatewayClientAdapter"/> mapped from the bound
|
||||
/// <see cref="ServerHistorianOptions"/> — the <b>same single gateway</b> the read path
|
||||
/// (<see cref="CreateDataSource"/>) and alarm-write path (<see cref="CreateAlarmWriter"/>) target. The
|
||||
/// Host's <c>AddHistorianProvisioning</c> wiring supplies this as the concrete
|
||||
/// <see cref="IHistorianProvisioning"/> the <c>AddressSpaceApplier</c> calls (non-blocking
|
||||
/// <c>EnsureTags</c>) when a deploy adds historized tags, so a brand-new historized tag exists in the
|
||||
/// historian before the recorder's <c>WriteLiveValues</c> land. Resolves an <see cref="ILoggerFactory"/>
|
||||
/// and the provisioner's <see cref="ILogger{TCategoryName}"/> from <paramref name="services"/>, falling
|
||||
/// back to the null implementations when absent. Performs no network I/O — the underlying channel dials
|
||||
/// on first <c>EnsureTags</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Like <see cref="CreateAlarmWriter"/>, this constructs its <b>own</b>
|
||||
/// <see cref="HistorianGatewayClientAdapter"/> — a separate gRPC channel to the same gateway. A second
|
||||
/// channel to a co-located sidecar is cheap (the gateway pools the historian sessions server-side), and
|
||||
/// it keeps each path's channel lifetime clean and independent.
|
||||
/// </remarks>
|
||||
/// <param name="options">The bound <c>ServerHistorian</c> configuration (endpoint, key, TLS posture).</param>
|
||||
/// <param name="services">The resolving service provider (used only to locate logging services).</param>
|
||||
/// <returns>The gateway-backed <see cref="IHistorianProvisioning"/>.</returns>
|
||||
public static IHistorianProvisioning CreateProvisioner(ServerHistorianOptions options, IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var loggerFactory = services.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var logger = services.GetService<ILogger<GatewayTagProvisioner>>()
|
||||
?? NullLogger<GatewayTagProvisioner>.Instance;
|
||||
|
||||
return new GatewayTagProvisioner(
|
||||
HistorianGatewayClientAdapter.Create(options, loggerFactory),
|
||||
logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ if (roleSuffix is not null)
|
||||
builder.Configuration.AddCommandLine(args);
|
||||
}
|
||||
|
||||
// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
|
||||
// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
|
||||
// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
|
||||
// (appsettings "logs/otopcua-.log") into System32 and made on-box diagnosis awkward; the same anchor also
|
||||
// roots the script log, the historization outbox, and any other relative artifact path. Done AFTER
|
||||
// WebApplication.CreateBuilder (which has already resolved the content root + appsettings.json) and BEFORE
|
||||
// AddZbSerilog (which instantiates the file sink), so only path resolution moves — config loading is
|
||||
// untouched. UseWindowsService already roots the content root at the base dir, so this only affects sinks.
|
||||
Directory.SetCurrentDirectory(AppContext.BaseDirectory);
|
||||
|
||||
// Serilog — shared ZB.MOM.WW.Telemetry bootstrap. Sinks (Console + rolling daily file)
|
||||
// now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the
|
||||
// shared NodeHostname / TraceContext / Redaction enrichers and trace correlation.
|
||||
@@ -125,6 +135,17 @@ if (hasDriver)
|
||||
builder.Configuration,
|
||||
(opts, sp) => GatewayHistorian.CreateDataSource(opts, sp));
|
||||
|
||||
// Config-gated historian tag provisioning. When the ServerHistorian section is enabled this registers the
|
||||
// gateway-backed GatewayTagProvisioner (overriding the NullHistorianProvisioning default) so that deploying
|
||||
// a historized tag auto-ensures it in the historian via the gateway's EnsureTags. The AddressSpaceApplier
|
||||
// (constructed in WithOtOpcUaRuntimeActors) resolves IHistorianProvisioning and fires a non-blocking
|
||||
// EnsureTagsAsync for added historized tags on every deploy; without this registration the applier falls
|
||||
// back to the no-op NullHistorianProvisioning and provisioning never reaches the gateway. Gated on the SAME
|
||||
// ServerHistorian:Enabled flag as the read path — both target the same single gateway.
|
||||
builder.Services.AddHistorianProvisioning(
|
||||
builder.Configuration,
|
||||
(opts, sp) => GatewayHistorian.CreateProvisioner(opts, sp));
|
||||
|
||||
// Continuous historization of driver (non-Galaxy) tag values. Gated on ContinuousHistorization:Enabled
|
||||
// AND the ServerHistorian gateway being configured: the recorder drains driver-tag live values to the
|
||||
// SAME single gateway's WriteLiveValues SQL path, sourcing endpoint/key/TLS from the ServerHistorian
|
||||
|
||||
@@ -271,13 +271,15 @@ public sealed class AddressSpaceApplier
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit a tally on EVERY successful dispatch (not only on Failed/Skipped) so a fully-
|
||||
// successful provisioning is observable AND a dormant no-op is detectable: the no-op
|
||||
// NullHistorianProvisioning reports Requested=0 regardless of input, so a line showing
|
||||
// dispatched={N} but requested=0 unmistakably flags "provisioning is not wired" — the
|
||||
// exact invisibility that let the dormant-provisioner bug ship unnoticed.
|
||||
var result = t.Result;
|
||||
if (result.Failed > 0 || result.Skipped > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: historian provisioning completed (requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
|
||||
result.Requested, result.Ensured, result.Skipped, result.Failed);
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
|
||||
provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed);
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.None,
|
||||
|
||||
@@ -47,6 +47,10 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||
services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance);
|
||||
// Historian tag provisioning. Null default (no-op) so the AddressSpaceApplier resolves a real
|
||||
// IHistorianProvisioning only when AddHistorianProvisioning registers the gateway-backed one
|
||||
// (ServerHistorian:Enabled). TryAddSingleton so the gateway registration wins last.
|
||||
services.TryAddSingleton<IHistorianProvisioning>(NullHistorianProvisioning.Instance);
|
||||
// VirtualTag historization sink. Null default — the durable AVEVA sink is infra-gated (there is
|
||||
// no live-data historian write RPC). TryAddSingleton so a deployment that bound a real
|
||||
// IHistoryWriter earlier wins.
|
||||
@@ -135,6 +139,39 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Config-gated historian tag provisioning. When the <c>ServerHistorian</c> section has
|
||||
/// <c>Enabled=true</c>, registers the <paramref name="provisioningFactory"/>-supplied
|
||||
/// <see cref="IHistorianProvisioning"/> (the gateway-backed <c>GatewayTagProvisioner</c> that calls
|
||||
/// the gateway's <c>EnsureTags</c>) overriding the <see cref="NullHistorianProvisioning"/> default from
|
||||
/// <see cref="AddOtOpcUaRuntime"/>. Otherwise a no-op (the Null default stays and deploying historized
|
||||
/// tags provisions nothing). The provisioner is consumed by the <c>AddressSpaceApplier</c>, which fires
|
||||
/// a non-blocking <see cref="IHistorianProvisioning.EnsureTagsAsync"/> for added historized tags on
|
||||
/// every deploy. Gated on the <b>same</b> <c>ServerHistorian:Enabled</c> flag as the read path
|
||||
/// (<see cref="AddServerHistorian"/>) — provisioning targets the same single gateway. The provisioner
|
||||
/// is injected so the gateway-backed client can be supplied by the Host, which is the only project that
|
||||
/// references the driver.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register with.</param>
|
||||
/// <param name="configuration">The configuration carrying the <c>ServerHistorian</c> section.</param>
|
||||
/// <param name="provisioningFactory">
|
||||
/// Factory the Host supplies to build the concrete <see cref="IHistorianProvisioning"/>
|
||||
/// (the gateway-backed provisioner) from the bound options + the resolving provider.
|
||||
/// </param>
|
||||
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
|
||||
public static IServiceCollection AddHistorianProvisioning(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
Func<ServerHistorianOptions, IServiceProvider, IHistorianProvisioning> provisioningFactory)
|
||||
{
|
||||
var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
|
||||
if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
|
||||
|
||||
// Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
|
||||
services.AddSingleton<IHistorianProvisioning>(sp => provisioningFactory(opts, sp));
|
||||
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"/>
|
||||
@@ -252,12 +289,19 @@ public static class ServiceCollectionExtensions
|
||||
? new ActorHistorizedTagSubscriptionSink(continuousRecorder)
|
||||
: NullHistorizedTagSubscriptionSink.Instance;
|
||||
|
||||
// Historian tag provisioner fed to the applier so deploying historized tags auto-ensures
|
||||
// them in the historian (EnsureTags). The Host registers the gateway-backed provisioner via
|
||||
// AddHistorianProvisioning when ServerHistorian:Enabled; otherwise this resolves the no-op
|
||||
// NullHistorianProvisioning default seeded by AddOtOpcUaRuntime (so the applier's hook is inert).
|
||||
var provisioning = resolver.GetService<IHistorianProvisioning>();
|
||||
|
||||
// OPC UA publish actor — pinned dispatcher, owns the address-space side of the
|
||||
// pipeline. AddressSpaceApplier is constructed here so the actor + applier share the
|
||||
// same sink reference (when DeferredAddressSpaceSink swaps later, both see it).
|
||||
var applier = new AddressSpaceApplier(
|
||||
addressSpaceSink,
|
||||
loggerFactory.CreateLogger<AddressSpaceApplier>(),
|
||||
provisioning: provisioning,
|
||||
historizedSubscriptions: historizedSubscriptions);
|
||||
var publishActor = system.ActorOf(
|
||||
OpcUaPublishActor.Props(
|
||||
|
||||
Reference in New Issue
Block a user