Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dd47ce707 | |||
| 257214f775 | |||
| 245316d80d |
@@ -264,6 +264,14 @@ deploy. Non-numeric (`String`/`DateTime`/`Reference`) data types are skipped (no
|
|||||||
recorder likewise drops + meters non-numeric values. Continuous historization is **numeric-analog only** in
|
recorder likewise drops + meters non-numeric values. Continuous historization is **numeric-analog only** in
|
||||||
v1 (`UInt16→UInt4` is a documented fallback).
|
v1 (`UInt16→UInt4` is a documented fallback).
|
||||||
|
|
||||||
|
The provisioner is registered by the Host's `AddHistorianProvisioning` (`Runtime.ServiceCollectionExtensions`),
|
||||||
|
built via `GatewayHistorian.CreateProvisioner`, and **gated on the same `ServerHistorian:Enabled` flag as the
|
||||||
|
read path** — `WithOtOpcUaRuntimeActors` resolves `IHistorianProvisioning` and passes it into the applier.
|
||||||
|
When the section is disabled the applier binds the no-op `NullHistorianProvisioning` and provisioning does not
|
||||||
|
run. Every dispatch logs a tally (`dispatched/requested/ensured/skipped/failed`); `dispatched=N` with
|
||||||
|
`requested=0` flags the dormant no-op. The API key must carry `historian:tags:write` for `EnsureTags`. (PR
|
||||||
|
#423 shipped `GatewayTagProvisioner` but left this wiring out, so provisioning was dormant; restored 2026-06-27.)
|
||||||
|
|
||||||
### Gateway-side prerequisites
|
### Gateway-side prerequisites
|
||||||
|
|
||||||
The target HistorianGateway OtOpcUa points at **must** run with:
|
The target HistorianGateway OtOpcUa points at **must** run with:
|
||||||
|
|||||||
+28
-1
@@ -82,7 +82,7 @@ and all HistoryRead calls on historized nodes return `GoodNoData` (empty, not an
|
|||||||
| `UseTls` | bool | `true` | Connect over TLS; must match the `Endpoint` scheme. |
|
| `UseTls` | bool | `true` | Connect over TLS; must match the `Endpoint` scheme. |
|
||||||
| `AllowUntrustedServerCertificate` | bool | `false` | Accept a self-signed / untrusted server certificate (dev / on-prem only). |
|
| `AllowUntrustedServerCertificate` | bool | `false` | Accept a self-signed / untrusted server certificate (dev / on-prem only). |
|
||||||
| `CaCertificatePath` | string\|null | `null` | PEM CA file pinning the gateway's TLS chain. Null/empty uses the OS trust store. |
|
| `CaCertificatePath` | string\|null | `null` | PEM CA file pinning the gateway's TLS chain. Null/empty uses the OS trust store. |
|
||||||
| `CallTimeout` | TimeSpan | `00:00:30` | Per-call deadline applied to each unary gateway read. |
|
| `CallTimeout` | TimeSpan | `00:00:30` | Per-call deadline applied to each unary gateway read. A read for a tag the historian does not yet know about — e.g. a freshly-historized tag that has **not** been provisioned (see [Tag auto-provisioning](#tag-auto-provisioning-ensuretags)) — can run to this **full** deadline before the gateway errors, so an unprovisioned-tag HistoryRead can block for ~30 s. Lower this to fail faster, at the cost of truncating legitimately long reads. |
|
||||||
| `MaxTieClusterOverfetch` | int | `65536` | Maximum samples the server will fetch in one shot to page through a tie cluster (multiple samples sharing one `SourceTimestamp`). A cluster larger than this ceiling fails `BadHistoryOperationUnsupported`. Raise to handle abnormally large tie clusters; the default covers all normal-data cases. |
|
| `MaxTieClusterOverfetch` | int | `65536` | Maximum samples the server will fetch in one shot to page through a tie cluster (multiple samples sharing one `SourceTimestamp`). A cluster larger than this ceiling fails `BadHistoryOperationUnsupported`. Raise to handle abnormally large tie clusters; the default covers all normal-data cases. |
|
||||||
|
|
||||||
> **Do not commit `ApiKey` to `appsettings.json`.** Set it via the environment variable
|
> **Do not commit `ApiKey` to `appsettings.json`.** Set it via the environment variable
|
||||||
@@ -104,6 +104,33 @@ source it from there.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Tag auto-provisioning (EnsureTags)
|
||||||
|
|
||||||
|
When a deploy adds a historized tag, the server auto-ensures it exists in the historian via the gateway's
|
||||||
|
`EnsureTags` **before** the first value or HistoryRead lands. `AddressSpaceApplier.Apply()` fires a
|
||||||
|
**non-blocking, fire-and-forget** `IHistorianProvisioning.EnsureTagsAsync` for each added historized value
|
||||||
|
tag (with the resolved historian name — the `historianTagname` override when set, else the driver
|
||||||
|
`FullName`); the hook is wrapped so a faulted, slow, or throwing provisioner can **never** block or fail a
|
||||||
|
deploy. Non-numeric (`String`/`DateTime`/`Reference`) types are skipped, not provisioned.
|
||||||
|
|
||||||
|
**Wiring + gating.** The concrete provisioner is the gateway-backed `GatewayTagProvisioner`, registered by
|
||||||
|
the Host's `AddHistorianProvisioning` (`GatewayHistorian.CreateProvisioner`) and gated on the **same**
|
||||||
|
`ServerHistorian:Enabled` flag as the read path — both target the same single gateway. When the section is
|
||||||
|
disabled (or absent) the applier resolves the no-op `NullHistorianProvisioning` and **provisioning does not
|
||||||
|
run** (so on a read-only-historian deployment, ensure tags exist by some other means). The Host requires the
|
||||||
|
API key to carry the `historian:tags:write` scope for `EnsureTags` to succeed.
|
||||||
|
|
||||||
|
**Observability.** Every dispatch logs an Information tally —
|
||||||
|
`historian provisioning completed (dispatched=N, requested=…, ensured=…, skipped=…, failed=…)`. A line
|
||||||
|
showing `dispatched=N` but `requested=0` indicates the **no-op** provisioner ran (provisioning is not wired
|
||||||
|
for this node), which is the signal to check that `ServerHistorian:Enabled=true`.
|
||||||
|
|
||||||
|
> **Why this matters for HistoryRead latency.** Until a tag is provisioned, the historian does not know it,
|
||||||
|
> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy
|
||||||
|
> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## HistoryRead behavior
|
## HistoryRead behavior
|
||||||
|
|
||||||
### Read variants
|
### Read variants
|
||||||
|
|||||||
+36
@@ -78,4 +78,40 @@ public static class GatewayHistorian
|
|||||||
HistorianGatewayClientAdapter.Create(options, loggerFactory),
|
HistorianGatewayClientAdapter.Create(options, loggerFactory),
|
||||||
logger);
|
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);
|
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)
|
// Serilog — shared ZB.MOM.WW.Telemetry bootstrap. Sinks (Console + rolling daily file)
|
||||||
// now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the
|
// now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the
|
||||||
// shared NodeHostname / TraceContext / Redaction enrichers and trace correlation.
|
// shared NodeHostname / TraceContext / Redaction enrichers and trace correlation.
|
||||||
@@ -125,6 +135,17 @@ if (hasDriver)
|
|||||||
builder.Configuration,
|
builder.Configuration,
|
||||||
(opts, sp) => GatewayHistorian.CreateDataSource(opts, sp));
|
(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
|
// 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
|
// 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
|
// SAME single gateway's WriteLiveValues SQL path, sourcing endpoint/key/TLS from the ServerHistorian
|
||||||
|
|||||||
@@ -271,13 +271,15 @@ public sealed class AddressSpaceApplier
|
|||||||
return;
|
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;
|
var result = t.Result;
|
||||||
if (result.Failed > 0 || result.Skipped > 0)
|
_logger.LogInformation(
|
||||||
{
|
"AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
|
||||||
_logger.LogInformation(
|
provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed);
|
||||||
"AddressSpaceApplier: historian provisioning completed (requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
|
|
||||||
result.Requested, result.Ensured, result.Skipped, result.Failed);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
CancellationToken.None,
|
CancellationToken.None,
|
||||||
TaskContinuationOptions.None,
|
TaskContinuationOptions.None,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||||
services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.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
|
// 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
|
// no live-data historian write RPC). TryAddSingleton so a deployment that bound a real
|
||||||
// IHistoryWriter earlier wins.
|
// IHistoryWriter earlier wins.
|
||||||
@@ -135,6 +139,39 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
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>
|
/// <summary>
|
||||||
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
|
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
|
||||||
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
|
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
|
||||||
@@ -252,12 +289,19 @@ public static class ServiceCollectionExtensions
|
|||||||
? new ActorHistorizedTagSubscriptionSink(continuousRecorder)
|
? new ActorHistorizedTagSubscriptionSink(continuousRecorder)
|
||||||
: NullHistorizedTagSubscriptionSink.Instance;
|
: 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
|
// OPC UA publish actor — pinned dispatcher, owns the address-space side of the
|
||||||
// pipeline. AddressSpaceApplier is constructed here so the actor + applier share the
|
// pipeline. AddressSpaceApplier is constructed here so the actor + applier share the
|
||||||
// same sink reference (when DeferredAddressSpaceSink swaps later, both see it).
|
// same sink reference (when DeferredAddressSpaceSink swaps later, both see it).
|
||||||
var applier = new AddressSpaceApplier(
|
var applier = new AddressSpaceApplier(
|
||||||
addressSpaceSink,
|
addressSpaceSink,
|
||||||
loggerFactory.CreateLogger<AddressSpaceApplier>(),
|
loggerFactory.CreateLogger<AddressSpaceApplier>(),
|
||||||
|
provisioning: provisioning,
|
||||||
historizedSubscriptions: historizedSubscriptions);
|
historizedSubscriptions: historizedSubscriptions);
|
||||||
var publishActor = system.ActorOf(
|
var publishActor = system.ActorOf(
|
||||||
OpcUaPublishActor.Props(
|
OpcUaPublishActor.Props(
|
||||||
|
|||||||
+72
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -143,6 +144,77 @@ public sealed class AddressSpaceApplierProvisioningTests
|
|||||||
prov.Seen[0].TagName.ShouldBe("Pump1.Good");
|
prov.Seen[0].TagName.ShouldBe("Pump1.Good");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Capturing <see cref="ILogger{T}"/> that records every log line and signals a
|
||||||
|
/// <see cref="TaskCompletionSource{TResult}"/> the first time a "historian provisioning completed" tally is
|
||||||
|
/// emitted, so the fire-and-forget continuation's log can be awaited deterministically.</summary>
|
||||||
|
private sealed class CapturingLogger : ILogger<AddressSpaceApplier>
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<string> _provisioningLogged =
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
/// <summary>Completes with the rendered message of the first provisioning-completed tally logged.</summary>
|
||||||
|
public Task<string> ProvisioningLogged => _provisioningLogged.Task;
|
||||||
|
|
||||||
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
var message = formatter(state, exception);
|
||||||
|
if (logLevel == LogLevel.Information &&
|
||||||
|
message.Contains("historian provisioning completed", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_provisioningLogged.TrySetResult(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullScope Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Issue 2 (observability): a fully-successful provisioning (Ensured>0, Failed=0, Skipped=0) — the
|
||||||
|
/// case that previously logged NOTHING — now emits an Information tally, so a successful provisioning is
|
||||||
|
/// observable rather than silent.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_logs_an_information_tally_on_a_fully_successful_provisioning()
|
||||||
|
{
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var applier = new AddressSpaceApplier(
|
||||||
|
NullOpcUaAddressSpaceSink.Instance, logger, new CapturingProvisioner());
|
||||||
|
|
||||||
|
applier.Apply(PlanWithAddedTags(
|
||||||
|
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
|
||||||
|
|
||||||
|
var message = await logger.ProvisioningLogged.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||||
|
message.ShouldContain("dispatched=1");
|
||||||
|
message.ShouldContain("ensured=1");
|
||||||
|
message.ShouldContain("failed=0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Issue 2 (observability): the no-op <see cref="NullHistorianProvisioning"/> path is now
|
||||||
|
/// DETECTABLE — it reports Requested=0 regardless of input, so a tally showing dispatched=1 but requested=0
|
||||||
|
/// unmistakably flags "provisioning is not wired", the invisibility that let the dormant-provisioner bug
|
||||||
|
/// ship unnoticed.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Apply_logs_a_detectable_tally_for_the_noop_provisioning_path()
|
||||||
|
{
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, logger);
|
||||||
|
|
||||||
|
applier.Apply(PlanWithAddedTags(
|
||||||
|
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
|
||||||
|
|
||||||
|
var message = await logger.ProvisioningLogged.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||||
|
message.ShouldContain("dispatched=1"); // we DID hand one request to EnsureTagsAsync
|
||||||
|
message.ShouldContain("requested=0"); // ...but the no-op acknowledges nothing ⇒ dormant
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Capturing <see cref="IHistorizedTagSubscriptionSink"/> double. Records the add/remove
|
/// <summary>Capturing <see cref="IHistorizedTagSubscriptionSink"/> double. Records the add/remove
|
||||||
/// ref deltas the applier feeds it. A <see cref="Throw"/> flag simulates a faulting feed.</summary>
|
/// ref deltas the applier feeds it. A <see cref="Throw"/> flag simulates a faulting feed.</summary>
|
||||||
private sealed class CapturingSubscriptionSink : IHistorizedTagSubscriptionSink
|
private sealed class CapturingSubscriptionSink : IHistorizedTagSubscriptionSink
|
||||||
|
|||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the config-gated <c>AddHistorianProvisioning</c> registration AND that a registered
|
||||||
|
/// provisioner is actually reached by the address-space applier on a historized-tag deploy. This is the
|
||||||
|
/// wiring the HistorianGateway-integration review found missing: PR #423 shipped
|
||||||
|
/// <c>GatewayTagProvisioner</c> + unit tests but never registered it in DI nor passed it into the applier,
|
||||||
|
/// so <c>EnsureTags</c> was dormant (every deploy used the no-op <see cref="NullHistorianProvisioning"/>).
|
||||||
|
/// <para>
|
||||||
|
/// When the <c>ServerHistorian</c> section is absent or disabled the <see cref="NullHistorianProvisioning"/>
|
||||||
|
/// default seeded by <c>AddOtOpcUaRuntime</c> survives (the factory is never invoked); when it is enabled
|
||||||
|
/// the factory's returned <see cref="IHistorianProvisioning"/> wins (last-registration-wins over the
|
||||||
|
/// <c>TryAddSingleton</c> Null default) and — constructed into the applier the way
|
||||||
|
/// <c>WithOtOpcUaRuntimeActors</c> does — its <see cref="IHistorianProvisioning.EnsureTagsAsync"/> fires
|
||||||
|
/// for an added historized tag.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddHistorianProvisioningTests
|
||||||
|
{
|
||||||
|
/// <summary>Capturing <see cref="IHistorianProvisioning"/> double — records the requests it was handed and
|
||||||
|
/// signals a <see cref="TaskCompletionSource"/> when invoked so the fire-and-forget applier dispatch can be
|
||||||
|
/// awaited deterministically.</summary>
|
||||||
|
private sealed class CapturingProvisioner : IHistorianProvisioning
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource _called = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
/// <summary>The requests the applier handed to <see cref="EnsureTagsAsync"/>.</summary>
|
||||||
|
public List<HistorianTagProvisionRequest> Seen { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Completes once <see cref="EnsureTagsAsync"/> has been invoked.</summary>
|
||||||
|
public Task Called => _called.Task;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<HistorianProvisionResult> EnsureTagsAsync(
|
||||||
|
IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Seen.AddRange(requests);
|
||||||
|
_called.TrySetResult();
|
||||||
|
return Task.FromResult(new HistorianProvisionResult(requests.Count, requests.Count, 0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IConfiguration ConfigFrom(Dictionary<string, string?> values)
|
||||||
|
=> new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||||
|
|
||||||
|
private static IConfiguration EnabledConfig() => ConfigFrom(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ServerHistorian:Enabled"] = "true",
|
||||||
|
["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
|
||||||
|
["ServerHistorian:ApiKey"] = "histgw_x_y",
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Section_absent_keeps_null_provisioning_and_factory_not_invoked()
|
||||||
|
{
|
||||||
|
var factoryInvoked = false;
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddOtOpcUaRuntime();
|
||||||
|
|
||||||
|
services.AddHistorianProvisioning(ConfigFrom(new Dictionary<string, string?>()), (_, _) =>
|
||||||
|
{
|
||||||
|
factoryInvoked = true;
|
||||||
|
return new CapturingProvisioner();
|
||||||
|
});
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
provider.GetRequiredService<IHistorianProvisioning>().ShouldBeSameAs(NullHistorianProvisioning.Instance);
|
||||||
|
factoryInvoked.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Section_disabled_keeps_null_provisioning_and_factory_not_invoked()
|
||||||
|
{
|
||||||
|
var factoryInvoked = false;
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddOtOpcUaRuntime();
|
||||||
|
var config = ConfigFrom(new Dictionary<string, string?> { ["ServerHistorian:Enabled"] = "false" });
|
||||||
|
|
||||||
|
services.AddHistorianProvisioning(config, (_, _) =>
|
||||||
|
{
|
||||||
|
factoryInvoked = true;
|
||||||
|
return new CapturingProvisioner();
|
||||||
|
});
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
provider.GetRequiredService<IHistorianProvisioning>().ShouldBeSameAs(NullHistorianProvisioning.Instance);
|
||||||
|
factoryInvoked.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Section_enabled_registers_factory_provisioner_over_the_null_default()
|
||||||
|
{
|
||||||
|
var prov = new CapturingProvisioner();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddOtOpcUaRuntime();
|
||||||
|
|
||||||
|
services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov);
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var resolved = provider.GetRequiredService<IHistorianProvisioning>();
|
||||||
|
resolved.ShouldBeSameAs(prov);
|
||||||
|
resolved.ShouldNotBeSameAs(NullHistorianProvisioning.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Section_enabled_passes_bound_options_to_factory()
|
||||||
|
{
|
||||||
|
ServerHistorianOptions? seen = null;
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddOtOpcUaRuntime();
|
||||||
|
var config = ConfigFrom(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ServerHistorian:Enabled"] = "true",
|
||||||
|
["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
|
||||||
|
["ServerHistorian:ApiKey"] = "histgw_x_y",
|
||||||
|
["ServerHistorian:UseTls"] = "true",
|
||||||
|
["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem",
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHistorianProvisioning(config, (opts, _) =>
|
||||||
|
{
|
||||||
|
seen = opts;
|
||||||
|
return new CapturingProvisioner();
|
||||||
|
});
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
_ = provider.GetRequiredService<IHistorianProvisioning>();
|
||||||
|
|
||||||
|
seen.ShouldNotBeNull();
|
||||||
|
seen.Endpoint.ShouldBe("https://historian.example.com:5222");
|
||||||
|
seen.ApiKey.ShouldBe("histgw_x_y");
|
||||||
|
seen.UseTls.ShouldBeTrue();
|
||||||
|
seen.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The end-to-end wiring proof the dormant-provisioner bug needed: register the provisioner via
|
||||||
|
/// <c>AddHistorianProvisioning</c>, resolve it, construct the <see cref="AddressSpaceApplier"/> EXACTLY
|
||||||
|
/// as <c>WithOtOpcUaRuntimeActors</c> does (<c>provisioning: resolver.GetService<IHistorianProvisioning>()</c>),
|
||||||
|
/// then apply a plan that adds a historized tag and assert the gateway-backed
|
||||||
|
/// <see cref="IHistorianProvisioning.EnsureTagsAsync"/> actually fires (with the resolved historian name).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Registered_provisioner_is_invoked_by_the_applier_on_a_historized_deploy()
|
||||||
|
{
|
||||||
|
var prov = new CapturingProvisioner();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddOtOpcUaRuntime();
|
||||||
|
services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov);
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
// Mirror the production construction in WithOtOpcUaRuntimeActors: resolve the provisioner and pass it in.
|
||||||
|
var resolved = provider.GetService<IHistorianProvisioning>();
|
||||||
|
var applier = new AddressSpaceApplier(
|
||||||
|
NullOpcUaAddressSpaceSink.Instance, NullLogger<AddressSpaceApplier>.Instance, provisioning: resolved);
|
||||||
|
|
||||||
|
applier.Apply(PlanWithAddedTags(
|
||||||
|
HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"),
|
||||||
|
NonHistorizedTag(displayName: "Run", dataType: "Boolean")));
|
||||||
|
|
||||||
|
await prov.Called.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
prov.Seen.Count.ShouldBe(1);
|
||||||
|
prov.Seen[0].TagName.ShouldBe("Pump1.Temp");
|
||||||
|
prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||||
|
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
|
||||||
|
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
|
||||||
|
|
||||||
|
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
|
||||||
|
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
|
||||||
|
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
|
||||||
|
|
||||||
|
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
|
||||||
|
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
|
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||||
|
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||||
|
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||||
|
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||||
|
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||||
|
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||||
|
{
|
||||||
|
AddedEquipmentTags = tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user