diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs index 3edbfaf..d5d3423 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs @@ -19,6 +19,8 @@ public sealed class DriverFactoryRegistry { private readonly Dictionary> _factories = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tiers + = new(StringComparer.OrdinalIgnoreCase); private readonly object _lock = new(); /// @@ -33,7 +35,15 @@ public sealed class DriverFactoryRegistry /// itself — the bootstrapper calls it via /// so the host's per-driver retry semantics apply uniformly. /// - public void Register(string driverType, Func factory) + /// + /// Stability tier per docs/v2/driver-stability.md. Defaults to + /// (in-process, lowest recycle footprint); Tier C + /// drivers SHOULD pass this explicitly so the scheduled-recycle wiring in + /// DriverInstanceBootstrapper can skip in-process drivers by tier check + /// rather than by driver-type allow-list. + /// + public void Register(string driverType, Func 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); } + /// + /// Look up the tier recorded alongside the factory. Returns + /// for unknown driver types — a missing registration is already a skipped-bootstrap + /// case upstream; we don't double-surface that failure here. + /// + public DriverTier GetTier(string driverType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverType); + lock (_lock) return _tiers.GetValueOrDefault(driverType, DriverTier.A); + } + public IReadOnlyCollection RegisteredTypes { get { lock (_lock) return [.. _factories.Keys]; } diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs index 8fe1497..b35ddf2 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptions.cs @@ -28,6 +28,16 @@ public sealed record DriverResilienceOptions /// public int BulkheadMaxQueue { get; init; } = 64; + /// + /// 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 ; 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). + /// + public int? RecycleIntervalSeconds { get; init; } + /// /// Look up the effective policy for a capability, falling back to tier defaults when no /// override is configured. Never returns null. diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs index b05b54a..e3753c2 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs @@ -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? CapabilityPolicies { get; set; } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs index a0a9ae8..073a92f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs @@ -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) diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs b/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs index 640670f..20348d8 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs @@ -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 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(instanceId, ...)` + // become eligible for scheduled recycle. Others silently skip. + private readonly IReadOnlyDictionary _supervisors = + scopeFactory.CreateScope().ServiceProvider + .GetServices() + .ToDictionary(s => s.DriverInstanceId, StringComparer.OrdinalIgnoreCase); public async Task 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()); + 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); + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index 79c4b6b..17d7943 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -117,6 +117,14 @@ builder.Services.AddSingleton(_ => }); builder.Services.AddSingleton(); +// 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(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + // 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 diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs index 3e97611..42b70b4 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceOptionsParserTests.cs @@ -163,4 +163,46 @@ public sealed class DriverResilienceOptionsParserTests foreach (var cap in Enum.GetValues()) 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"); + } }