Closes the gap surfaced by Phase 7 live smoke (#240): DriverInstance rows in the central config DB had no path to materialise as live IDriver instances in DriverHost, so virtual-tag scripts read BadNodeIdUnknown for every tag. ## DriverFactoryRegistry (Core.Hosting) Process-singleton type-name → factory map. Each driver project's static Register call pre-loads its factory at Program.cs startup; the bootstrapper looks up by DriverInstance.DriverType + invokes with (DriverInstanceId, DriverConfig JSON). Case-insensitive; duplicate-type registration throws. ## GalaxyProxyDriverFactoryExtensions.Register (Driver.Galaxy.Proxy) Static helper — no Microsoft.Extensions.DependencyInjection dep, keeps the driver project free of DI machinery. Parses DriverConfig JSON for PipeName + SharedSecret + ConnectTimeoutMs. DriverInstanceId from the row wins over JSON per the schema's UX_DriverInstance_Generation_LogicalId. ## DriverInstanceBootstrapper (Server) After NodeBootstrap loads the published generation: queries DriverInstance rows scoped to that generation, looks up the factory per row, constructs + DriverHost.RegisterAsync (which calls InitializeAsync). Per plan decision #12 (driver isolation), failure of one driver doesn't prevent others — logs ERR + continues + returns the count actually registered. Unknown DriverType (factory not registered) logs WRN + skips so a missing-assembly deployment doesn't take down the whole server. ## Wired into OpcUaServerService.ExecuteAsync After NodeBootstrap.LoadCurrentGenerationAsync, before PopulateEquipmentContentAsync + Phase7Composer.PrepareAsync. The Phase 7 chain now sees a populated DriverHost so CachedTagUpstreamSource has an upstream feed. ## Live evidence on the dev box Re-ran the Phase 7 smoke from task #240. Pre-#248 vs post-#248: Equipment namespace snapshots loaded for 0/0 driver(s) ← before Equipment namespace snapshots loaded for 1/1 driver(s) ← after Galaxy.Host pipe ACL denied our SID (env-config issue documented in docs/ServiceHosting.md, NOT a code issue) — the bootstrapper logged it as "failed to initialize, driver state will reflect Faulted" and continued past the failure exactly per plan #12. The rest of the pipeline (Equipment walker + Phase 7 composer) ran to completion. ## Tests — 5 new DriverFactoryRegistryTests Register + TryGet round-trip, case-insensitive lookup, duplicate-type throws, null-arg guards, RegisteredTypes snapshot. Pure functions; no DI/DB needed. The bootstrapper's DB-query path is exercised by the live smoke (#240) which operators run before each release.
112 lines
5.5 KiB
C#
112 lines
5.5 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||
|
||
/// <summary>
|
||
/// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf).
|
||
/// Bootstraps config, starts the <see cref="DriverHost"/>, starts the OPC UA server via
|
||
/// <see cref="OpcUaApplicationHost"/>, drives each driver's discovery into the address space,
|
||
/// runs until stopped.
|
||
/// </summary>
|
||
public sealed class OpcUaServerService(
|
||
NodeBootstrap bootstrap,
|
||
DriverHost driverHost,
|
||
OpcUaApplicationHost applicationHost,
|
||
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||
DriverInstanceBootstrapper driverBootstrapper,
|
||
Phase7Composer phase7Composer,
|
||
IServiceScopeFactory scopeFactory,
|
||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||
{
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||
{
|
||
logger.LogInformation("OtOpcUa.Server starting");
|
||
|
||
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
|
||
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
|
||
|
||
// ADR-001 Option A — populate per-driver Equipment namespace snapshots into the
|
||
// registry before StartAsync walks the address space. The walker on the OPC UA side
|
||
// reads synchronously from the registry; pre-loading here means the hot path stays
|
||
// non-blocking + each driver pays at most one Config-DB query at bootstrap time.
|
||
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
|
||
// address space until the first publish, then the registry fills on next restart.
|
||
if (result.GenerationId is { } gen)
|
||
{
|
||
// Task #248 — register IDriver instances from the published DriverInstance
|
||
// rows BEFORE the equipment-content load + Phase 7 compose, so the rest of
|
||
// the pipeline sees a populated DriverHost. Without this step Phase 7's
|
||
// CachedTagUpstreamSource has no upstream feed + virtual-tag scripts read
|
||
// BadNodeIdUnknown for every tag path (gap surfaced by task #240 smoke).
|
||
await driverBootstrapper.RegisterDriversFromGenerationAsync(gen, stoppingToken);
|
||
|
||
await PopulateEquipmentContentAsync(gen, stoppingToken);
|
||
|
||
// Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows,
|
||
// compose VirtualTagEngine + ScriptedAlarmEngine, start the driver-bridge
|
||
// feed. SetPhase7Sources MUST run before applicationHost.StartAsync because
|
||
// OtOpcUaServer + DriverNodeManager construction captures the field values
|
||
// — late binding after server start is rejected with InvalidOperationException.
|
||
// No-op when the generation has no virtual tags or scripted alarms.
|
||
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
|
||
applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable);
|
||
}
|
||
|
||
await applicationHost.StartAsync(stoppingToken);
|
||
|
||
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
|
||
|
||
try
|
||
{
|
||
await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
logger.LogInformation("OtOpcUa.Server stopping");
|
||
}
|
||
}
|
||
|
||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||
{
|
||
await base.StopAsync(cancellationToken);
|
||
// Dispose Phase 7 first so the bridge stops feeding the cache + the engines
|
||
// stop firing alarm/historian events before the OPC UA server tears down its
|
||
// node managers. Otherwise an in-flight cascade could try to push through a
|
||
// disposed source and surface as a noisy shutdown warning.
|
||
await phase7Composer.DisposeAsync();
|
||
await applicationHost.DisposeAsync();
|
||
await driverHost.DisposeAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pre-load an <c>EquipmentNamespaceContent</c> snapshot for each registered driver at
|
||
/// the bootstrapped generation. Null results (driver has no Equipment rows —
|
||
/// Modbus/AB CIP/TwinCAT/FOCAS today per decisions #116–#121) are skipped: the walker
|
||
/// wire-in sees Get(driverId) return null + falls back to DiscoverAsync-owns-it.
|
||
/// Opens one scope so the scoped <c>OtOpcUaConfigDbContext</c> is shared across all
|
||
/// per-driver queries rather than paying scope-setup overhead per driver.
|
||
/// </summary>
|
||
private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct)
|
||
{
|
||
using var scope = scopeFactory.CreateScope();
|
||
var loader = scope.ServiceProvider.GetRequiredService<EquipmentNamespaceContentLoader>();
|
||
|
||
var loaded = 0;
|
||
foreach (var driverId in driverHost.RegisteredDriverIds)
|
||
{
|
||
var content = await loader.LoadAsync(driverId, generationId, ct).ConfigureAwait(false);
|
||
if (content is null) continue;
|
||
equipmentContentRegistry.Set(driverId, content);
|
||
loaded++;
|
||
}
|
||
logger.LogInformation(
|
||
"Equipment namespace snapshots loaded for {Count}/{Total} driver(s) at generation {Gen}",
|
||
loaded, driverHost.RegisteredDriverIds.Count, generationId);
|
||
}
|
||
}
|