Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
T
Joseph Doherty bd6568bcbd Phase 6.1 Stream B.4 — wire ScheduledRecycleHostedService into bootstrap
Task #125 / #137. The hosted service + scheduler classes already shipped;
this commit connects them to the published-generation driver list so a
Tier C driver with `RecycleIntervalSeconds` in its `ResilienceConfig`
actually gets an armed scheduler at bootstrap.

Wiring:

- `DriverFactoryRegistry.Register` gains an optional `DriverTier`
  parameter (default Tier.A). Existing call sites unchanged —
  `GalaxyProxyDriverFactoryExtensions.Register` explicitly passes
  Tier.C so the bootstrapper can identify out-of-process drivers
  without a per-driver-type allow-list.
- `DriverResilienceOptions` + parser grow `RecycleIntervalSeconds`.
  Tier A/B values are rejected with a diagnostic (decision #74 —
  recycling an in-process driver would kill every OPC UA session).
  Non-positive values are rejected the same way.
- `DriverInstanceBootstrapper` auto-arms a `ScheduledRecycleScheduler`
  after a successful driver register when: (1) the registered tier is
  C, (2) the row's ResilienceConfig carries a positive recycle interval,
  (3) DI has an `IDriverSupervisor` keyed by that `DriverInstanceId`.
  Missing supervisor → warn + skip (no crash). That keeps the wiring
  harmless by default: no driver ships a supervisor today, so the
  hosted service runs with zero schedulers out of the box.
- `Program.cs` registers `ScheduledRecycleHostedService` as singleton
  (shared with `DriverInstanceBootstrapper`) + hosted service (drives
  the tick loop). Constructor changes on the bootstrapper ripple into
  DI resolution automatically.

Tests: 4 new parser tests covering RecycleIntervalSeconds on Tier C
happy path, null default, Tier A/B rejection, non-positive rejection.
Existing 283 Server.Tests + 200 Core.Tests all still green.

No behavioural change for existing deployments: Galaxy driver + any
future Tier C driver gain the opt-in automatically; Tier A/B drivers
(FOCAS, Modbus, S7, AB CIP, AB Legacy, TwinCAT) are structurally
excluded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:58:13 -04:00

87 lines
3.9 KiB
C#

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 Dictionary<string, DriverTier> _tiers
= 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>
/// <param name="tier">
/// Stability tier per <c>docs/v2/driver-stability.md</c>. Defaults to
/// <see cref="DriverTier.A"/> (in-process, lowest recycle footprint); Tier C
/// drivers SHOULD pass this explicitly so the scheduled-recycle wiring in
/// <c>DriverInstanceBootstrapper</c> can skip in-process drivers by tier check
/// rather than by driver-type allow-list.
/// </param>
public void Register(string driverType, Func<string, string, IDriver> factory,
DriverTier tier = DriverTier.A)
{
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;
_tiers[driverType] = tier;
}
}
/// <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);
}
/// <summary>
/// Look up the tier recorded alongside the factory. Returns <see cref="DriverTier.A"/>
/// for unknown driver types — a missing registration is already a skipped-bootstrap
/// case upstream; we don't double-surface that failure here.
/// </summary>
public DriverTier GetTier(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
lock (_lock) return _tiers.GetValueOrDefault(driverType, DriverTier.A);
}
public IReadOnlyCollection<string> RegisteredTypes
{
get { lock (_lock) return [.. _factories.Keys]; }
}
}