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:
@@ -19,6 +19,8 @@ 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>
|
||||
@@ -33,7 +35,15 @@ public sealed class DriverFactoryRegistry
|
||||
/// 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)
|
||||
/// <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);
|
||||
@@ -43,6 +53,7 @@ public sealed class DriverFactoryRegistry
|
||||
throw new InvalidOperationException(
|
||||
$"DriverType '{driverType}' factory already registered for this process");
|
||||
_factories[driverType] = factory;
|
||||
_tiers[driverType] = tier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +68,17 @@ public sealed class DriverFactoryRegistry
|
||||
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]; }
|
||||
|
||||
@@ -28,6 +28,16 @@ public sealed record DriverResilienceOptions
|
||||
/// </summary>
|
||||
public int BulkheadMaxQueue { get; init; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Periodic scheduled recycle interval for Tier C out-of-process hosts, in seconds.
|
||||
/// Null (the default) = no scheduled recycle; the driver's Host process keeps running
|
||||
/// indefinitely unless a memory breach or operator action triggers a recycle. Only
|
||||
/// respected for <see cref="DriverTier.C"/>; Tier A/B recycle would tear down every
|
||||
/// OPC UA session, so the loader ignores non-null values for those tiers + logs a
|
||||
/// warning (per decisions #74 / #145).
|
||||
/// </summary>
|
||||
public int? RecycleIntervalSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Look up the effective policy for a capability, falling back to tier defaults when no
|
||||
/// override is configured. Never returns null.
|
||||
|
||||
@@ -91,12 +91,27 @@ public static class DriverResilienceOptionsParser
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled recycle is Tier C only — reject a configured interval on Tier A/B as a
|
||||
// misconfiguration surface rather than silently honouring it (recycling an in-process
|
||||
// driver would kill every OPC UA session + every co-hosted driver, per decision #74).
|
||||
int? recycleIntervalSeconds = null;
|
||||
if (shape.RecycleIntervalSeconds is int secs)
|
||||
{
|
||||
if (secs <= 0)
|
||||
parseDiagnostic ??= $"RecycleIntervalSeconds must be positive; got {secs} — ignoring.";
|
||||
else if (tier != DriverTier.C)
|
||||
parseDiagnostic ??= $"RecycleIntervalSeconds is Tier C only; Tier {tier} driver will not scheduled-recycle.";
|
||||
else
|
||||
recycleIntervalSeconds = secs;
|
||||
}
|
||||
|
||||
return new DriverResilienceOptions
|
||||
{
|
||||
Tier = tier,
|
||||
CapabilityPolicies = merged,
|
||||
BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent ?? baseOptions.BulkheadMaxConcurrent,
|
||||
BulkheadMaxQueue = shape.BulkheadMaxQueue ?? baseOptions.BulkheadMaxQueue,
|
||||
RecycleIntervalSeconds = recycleIntervalSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +119,7 @@ public static class DriverResilienceOptionsParser
|
||||
{
|
||||
public int? BulkheadMaxConcurrent { get; set; }
|
||||
public int? BulkheadMaxQueue { get; set; }
|
||||
public int? RecycleIntervalSeconds { get; set; }
|
||||
public Dictionary<string, CapabilityPolicyShape>? CapabilityPolicies { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
@@ -21,7 +22,8 @@ public static class GalaxyProxyDriverFactoryExtensions
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
// Galaxy is Tier C — out-of-process MXAccess Host, scheduled recycle is allowed.
|
||||
registry.Register(DriverTypeName, CreateInstance, DriverTier.C);
|
||||
}
|
||||
|
||||
internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -163,4 +163,46 @@ public sealed class DriverResilienceOptionsParserTests
|
||||
foreach (var cap in Enum.GetValues<DriverCapability>())
|
||||
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
||||
DriverTier.C, "{\"recycleIntervalSeconds\":3600}", out var diag);
|
||||
|
||||
diag.ShouldBeNull();
|
||||
options.RecycleIntervalSeconds.ShouldBe(3600);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_Null_DefaultsToNull()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.C, "{}", out _);
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
public void RecycleIntervalSeconds_OnTierAorB_Rejected_With_Diagnostic(DriverTier tier)
|
||||
{
|
||||
// Decision #74 — in-process drivers must not scheduled-recycle because it would
|
||||
// tear down every OPC UA session. The parser surfaces a diagnostic rather than
|
||||
// silently honouring the value.
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
||||
tier, "{\"recycleIntervalSeconds\":3600}", out var diag);
|
||||
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
diag.ShouldContain("Tier C only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecycleIntervalSeconds_NonPositive_Rejected_With_Diagnostic()
|
||||
{
|
||||
var options = DriverResilienceOptionsParser.ParseOrDefaults(
|
||||
DriverTier.C, "{\"recycleIntervalSeconds\":0}", out var diag);
|
||||
|
||||
options.RecycleIntervalSeconds.ShouldBeNull();
|
||||
diag.ShouldContain("must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user