Driver-instance bootstrap pipeline (#248) — DriverInstance rows materialise as live IDriver instances
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.
This commit is contained in:
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Process-singleton registry of <see cref="IDriver"/> factories keyed by
|
||||
/// <c>DriverInstance.DriverType</c> string. Each driver project ships a DI
|
||||
/// extension (e.g. <c>services.AddGalaxyProxyDriverFactory()</c>) that registers
|
||||
/// its factory at startup; the bootstrapper looks up the factory by
|
||||
/// <c>DriverInstance.DriverType</c> + invokes it with the row's
|
||||
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in
|
||||
/// the central config DB had no path to materialise as registered <see cref="IDriver"/>
|
||||
/// instances. The factory registry is the seam.
|
||||
/// </remarks>
|
||||
public sealed class DriverFactoryRegistry
|
||||
{
|
||||
private readonly Dictionary<string, Func<string, string, IDriver>> _factories
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Register a factory for <paramref name="driverType"/>. Throws if a factory is
|
||||
/// already registered for that type — drivers are singletons by type-name in
|
||||
/// this process.
|
||||
/// </summary>
|
||||
/// <param name="driverType">Matches <c>DriverInstance.DriverType</c>.</param>
|
||||
/// <param name="factory">
|
||||
/// Receives <c>(driverInstanceId, driverConfigJson)</c>; returns a new
|
||||
/// <see cref="IDriver"/>. Must NOT call <see cref="IDriver.InitializeAsync"/>
|
||||
/// itself — the bootstrapper calls it via <see cref="DriverHost.RegisterAsync"/>
|
||||
/// so the host's per-driver retry semantics apply uniformly.
|
||||
/// </param>
|
||||
public void Register(string driverType, Func<string, string, IDriver> factory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_factories.ContainsKey(driverType))
|
||||
throw new InvalidOperationException(
|
||||
$"DriverType '{driverType}' factory already registered for this process");
|
||||
_factories[driverType] = factory;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to look up the factory for <paramref name="driverType"/>. Returns null
|
||||
/// if no driver assembly registered one — bootstrapper logs + skips so a
|
||||
/// missing-assembly deployment doesn't take down the whole server.
|
||||
/// </summary>
|
||||
public Func<string, string, IDriver>? TryGet(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
lock (_lock) return _factories.GetValueOrDefault(driverType);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> RegisteredTypes
|
||||
{
|
||||
get { lock (_lock) return [.. _factories.Keys]; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="GalaxyProxyDriver"/>. Server's
|
||||
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper (task #248)
|
||||
/// then materialises Galaxy DriverInstance rows from the central config DB into live
|
||||
/// driver instances. No dependency on Microsoft.Extensions.DependencyInjection so the
|
||||
/// driver project stays free of DI machinery.
|
||||
/// </summary>
|
||||
public static class GalaxyProxyDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "Galaxy";
|
||||
|
||||
/// <summary>
|
||||
/// Register the Galaxy driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||
/// Throws if 'Galaxy' is already registered — single-instance per process.
|
||||
/// </summary>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
// DriverConfig column is a JSON object that mirrors GalaxyProxyOptions.
|
||||
// Required: PipeName, SharedSecret. Optional: ConnectTimeoutMs (defaults to 10s).
|
||||
// The DriverInstanceId from the row wins over any value in the JSON — the row
|
||||
// is the authoritative identity per the schema's UX_DriverInstance_Generation_LogicalId.
|
||||
using var doc = JsonDocument.Parse(driverConfigJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string pipeName = root.TryGetProperty("PipeName", out var p) && p.ValueKind == JsonValueKind.String
|
||||
? p.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required PipeName");
|
||||
string sharedSecret = root.TryGetProperty("SharedSecret", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString()!
|
||||
: throw new InvalidOperationException(
|
||||
$"GalaxyProxyDriver config for '{driverInstanceId}' missing required SharedSecret");
|
||||
var connectTimeout = root.TryGetProperty("ConnectTimeoutMs", out var t) && t.ValueKind == JsonValueKind.Number
|
||||
? TimeSpan.FromMilliseconds(t.GetInt32())
|
||||
: TimeSpan.FromSeconds(10);
|
||||
|
||||
return new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = driverInstanceId,
|
||||
PipeName = pipeName,
|
||||
SharedSecret = sharedSecret,
|
||||
ConnectTimeout = connectTimeout,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
88
src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
Normal file
88
src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Task #248 — bridges the gap surfaced by the Phase 7 live smoke (#240) where
|
||||
/// <c>DriverInstance</c> rows in the central config DB had no path to materialise
|
||||
/// as live <see cref="Core.Abstractions.IDriver"/> instances in <see cref="DriverHost"/>.
|
||||
/// Called from <c>OpcUaServerService.ExecuteAsync</c> after the bootstrap loads
|
||||
/// the published generation, before address-space build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per row: looks up the <c>DriverType</c> string in
|
||||
/// <see cref="DriverFactoryRegistry"/>, calls the factory with the row's
|
||||
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON to construct an
|
||||
/// <see cref="Core.Abstractions.IDriver"/>, then registers via
|
||||
/// <see cref="DriverHost.RegisterAsync"/> which invokes <c>InitializeAsync</c>
|
||||
/// under the host's lifecycle semantics.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unknown <c>DriverType</c> = factory not registered = log a warning and skip.
|
||||
/// Per plan decision #12 (driver isolation), failure to construct or initialize
|
||||
/// one driver doesn't prevent the rest from coming up — the Server keeps serving
|
||||
/// the others' subtrees + the operator can fix the misconfigured row + republish
|
||||
/// to retry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverInstanceBootstrapper(
|
||||
DriverFactoryRegistry factories,
|
||||
DriverHost driverHost,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DriverInstanceBootstrapper> logger)
|
||||
{
|
||||
public async Task<int> RegisterDriversFromGenerationAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
var rows = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.GenerationId == generationId && d.Enabled)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var registered = 0;
|
||||
var skippedUnknownType = 0;
|
||||
var failedInit = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var factory = factories.TryGet(row.DriverType);
|
||||
if (factory is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"DriverInstance {Id} skipped — DriverType '{Type}' has no registered factory (known: {Known})",
|
||||
row.DriverInstanceId, row.DriverType, string.Join(",", factories.RegisteredTypes));
|
||||
skippedUnknownType++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driver = factory(row.DriverInstanceId, row.DriverConfig);
|
||||
await driverHost.RegisterAsync(driver, row.DriverConfig, ct).ConfigureAwait(false);
|
||||
registered++;
|
||||
logger.LogInformation(
|
||||
"DriverInstance {Id} ({Type}) registered + initialized", row.DriverInstanceId, row.DriverType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Plan decision #12 — driver isolation. Log + continue so one bad row
|
||||
// doesn't deny the OPC UA endpoint to the rest of the fleet.
|
||||
logger.LogError(ex,
|
||||
"DriverInstance {Id} ({Type}) failed to initialize — driver state will reflect Faulted; operator can republish to retry",
|
||||
row.DriverInstanceId, row.DriverType);
|
||||
failedInit++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"DriverInstanceBootstrapper: gen={Gen} registered={Registered} skippedUnknownType={Skipped} failedInit={Failed}",
|
||||
generationId, registered, skippedUnknownType, failedInit);
|
||||
return registered;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed class OpcUaServerService(
|
||||
DriverHost driverHost,
|
||||
OpcUaApplicationHost applicationHost,
|
||||
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||||
DriverInstanceBootstrapper driverBootstrapper,
|
||||
Phase7Composer phase7Composer,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||
@@ -37,6 +38,13 @@ public sealed class OpcUaServerService(
|
||||
// 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,
|
||||
|
||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
@@ -89,6 +90,18 @@ builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(opti
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
|
||||
// Task #248 — driver-instance bootstrap pipeline. DriverFactoryRegistry is the
|
||||
// type-name → factory map; each driver project's static Register call pre-loads
|
||||
// its factory so the bootstrapper can materialise DriverInstance rows from the
|
||||
// central DB into live IDriver instances.
|
||||
builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
GalaxyProxyDriverFactoryExtensions.Register(registry);
|
||||
return registry;
|
||||
});
|
||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||
|
||||
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
|
||||
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
|
||||
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
BIN
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
Normal file
BIN
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
Normal file
Binary file not shown.
Reference in New Issue
Block a user