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>
This commit is contained in:
Joseph Doherty
2026-04-24 18:58:13 -04:00
parent a52086efc5
commit bd6568bcbd
7 changed files with 152 additions and 2 deletions

View File

@@ -2,7 +2,11 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Core.Stability;
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Server;
@@ -33,9 +37,20 @@ namespace ZB.MOM.WW.OtOpcUa.Server;
public sealed class DriverInstanceBootstrapper(
DriverFactoryRegistry factories,
DriverHost driverHost,
ScheduledRecycleHostedService recycleHost,
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory,
ILogger<DriverInstanceBootstrapper> logger)
{
// IDriverSupervisor instances, looked up by DriverInstanceId. The bootstrapper
// consults DI at run time because no driver ships a supervisor today — the
// dictionary is built from optional DI registrations; Tier C drivers that
// register one via `services.AddKeyedSingleton<IDriverSupervisor>(instanceId, ...)`
// become eligible for scheduled recycle. Others silently skip.
private readonly IReadOnlyDictionary<string, IDriverSupervisor> _supervisors =
scopeFactory.CreateScope().ServiceProvider
.GetServices<IDriverSupervisor>()
.ToDictionary(s => s.DriverInstanceId, StringComparer.OrdinalIgnoreCase);
public async Task<int> RegisterDriversFromGenerationAsync(long generationId, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
@@ -68,6 +83,13 @@ public sealed class DriverInstanceBootstrapper(
registered++;
logger.LogInformation(
"DriverInstance {Id} ({Type}) registered + initialized", row.DriverInstanceId, row.DriverType);
// Scheduled-recycle opt-in — only meaningful for Tier C out-of-process hosts,
// and only when the row's ResilienceConfig carries a positive
// RecycleIntervalSeconds AND the deployment wired an IDriverSupervisor for
// this DriverInstanceId. Silently skipping when any of those is absent is the
// intended zero-config-default behaviour.
TryRegisterScheduledRecycle(row.DriverInstanceId, row.DriverType, row.ResilienceConfig);
}
catch (Exception ex)
{
@@ -85,4 +107,32 @@ public sealed class DriverInstanceBootstrapper(
generationId, registered, skippedUnknownType, failedInit);
return registered;
}
private void TryRegisterScheduledRecycle(string driverInstanceId, string driverType, string? resilienceJson)
{
var tier = factories.GetTier(driverType);
if (tier != DriverTier.C) return;
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceJson, out _);
if (options.RecycleIntervalSeconds is not int secs) return;
if (!_supervisors.TryGetValue(driverInstanceId, out var supervisor))
{
logger.LogWarning(
"DriverInstance {Id} ({Type}) has RecycleIntervalSeconds={Secs} in ResilienceConfig but no IDriverSupervisor registered; scheduled recycle will not fire",
driverInstanceId, driverType, secs);
return;
}
var scheduler = new ScheduledRecycleScheduler(
tier,
TimeSpan.FromSeconds(secs),
DateTime.UtcNow,
supervisor,
loggerFactory.CreateLogger<ScheduledRecycleScheduler>());
recycleHost.AddScheduler(scheduler);
logger.LogInformation(
"Scheduled recycle armed for Tier C driver {Id} ({Type}) — interval {Interval}, first fire at {Next:o}",
driverInstanceId, driverType, TimeSpan.FromSeconds(secs), scheduler.NextRecycleUtc);
}
}

View File

@@ -117,6 +117,14 @@ builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
});
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
// Phase 6.1 Stream B.4 (task #137) — ScheduledRecycleHostedService. Empty scheduler
// list by default; DriverInstanceBootstrapper calls AddScheduler for any Tier C driver
// whose ResilienceConfig carries a RecycleIntervalSeconds AND has an IDriverSupervisor
// registered in DI. Registered as singleton so DriverInstanceBootstrapper can inject
// the same instance that the BackgroundService loop drives.
builder.Services.AddSingleton<ScheduledRecycleHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ScheduledRecycleHostedService>());
// 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