From c4a92f424af1ae0d440557c86e44a47bd628ca7c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 11:42:08 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206.1=20Stream=20B.4=20follow-up=20?= =?UTF-8?q?=E2=80=94=20ScheduledRecycleHostedService=20drives=20registered?= =?UTF-8?q?=20schedulers=20on=20a=20fixed=20tick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the Phase 6.1 Stream B.4 pure-logic ScheduledRecycleScheduler (shipped in PR #79) into a running background feature. A Tier C driver registers its scheduler at startup; the hosted service ticks every TickInterval (default 1 min) and invokes TickAsync on each registered scheduler. Server.Hosting: - ScheduledRecycleHostedService : BackgroundService. AddScheduler(s) must be called before StartAsync — registering post-start throws InvalidOperationException to avoid "some ticks saw my scheduler, some didn't" races. ExecuteAsync loops on Task.Delay(TickInterval, _timeProvider, stoppingToken) + delegates to a public TickOnceAsync method for one tick. - TickOnceAsync extracted as the unit-of-work so tests drive it directly without needing to synchronize with FakeTimeProvider + BackgroundService timing semantics. - Exception isolation: if one scheduler throws, the loop logs + continues to the next scheduler. A flaky supervisor can't take down the tick for every other Tier C driver. - Diagnostics: TickCount + SchedulerCount properties for tests + logs. Tests (7 new ScheduledRecycleHostedServiceTests, all pass): - TickOnce before interval doesn't fire; TickCount still advances. - TickOnce at/after interval fires the underlying scheduler exactly once. - Multiple ticks accumulate count. - AddScheduler after StartAsync throws. - Throwing scheduler doesn't poison its neighbours (logs + continues). - SchedulerCount matches registrations. - Empty scheduler list ticks cleanly (no-op + counter advances). Full solution dotnet test: 1193 passing (was 1186, +7). Pre-existing Client.CLI Subscribe flake unchanged. Production wiring (Program.cs): builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // During DI configuration, once Tier C drivers + their ScheduledRecycleSchedulers // are resolved, call host.AddScheduler(scheduler) for each. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Hosting/ScheduledRecycleHostedService.cs | 117 ++++++++++++++ .../ScheduledRecycleHostedServiceTests.cs | 152 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/Hosting/ScheduledRecycleHostedService.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScheduledRecycleHostedServiceTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Hosting/ScheduledRecycleHostedService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Hosting/ScheduledRecycleHostedService.cs new file mode 100644 index 0000000..08c8231 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Hosting/ScheduledRecycleHostedService.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Stability; + +namespace ZB.MOM.WW.OtOpcUa.Server.Hosting; + +/// +/// Drives one or more instances on a fixed tick +/// cadence. Closes Phase 6.1 Stream B.4 by turning the shipped-as-pure-logic scheduler +/// into a running background feature. +/// +/// +/// Registered as a singleton in Program.cs. Each Tier C driver instance that wants a +/// scheduled recycle registers its scheduler via +/// at startup. The hosted service +/// wakes every (default 1 min) and calls +/// on each registered scheduler. +/// +/// Scheduler registration is closed after starts — callers +/// must register before the host starts, typically during DI setup. Adding a scheduler +/// mid-flight throws to avoid confusing "some ticks saw my scheduler, some didn't" races. +/// +public sealed class ScheduledRecycleHostedService : BackgroundService +{ + private readonly List _schedulers = []; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private bool _started; + + /// How often fires on each registered scheduler. + public TimeSpan TickInterval { get; } + + public ScheduledRecycleHostedService( + ILogger logger, + TimeProvider? timeProvider = null, + TimeSpan? tickInterval = null) + { + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + TickInterval = tickInterval ?? TimeSpan.FromMinutes(1); + } + + /// Register a scheduler to drive. Must be called before the host starts. + public void AddScheduler(ScheduledRecycleScheduler scheduler) + { + ArgumentNullException.ThrowIfNull(scheduler); + if (_started) + throw new InvalidOperationException( + "Cannot register a ScheduledRecycleScheduler after the hosted service has started. " + + "Register all schedulers during DI configuration / startup."); + _schedulers.Add(scheduler); + } + + /// Snapshot of the current tick count — diagnostics only. + public int TickCount { get; private set; } + + /// Snapshot of the number of registered schedulers — diagnostics only. + public int SchedulerCount => _schedulers.Count; + + public override Task StartAsync(CancellationToken cancellationToken) + { + _started = true; + return base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "ScheduledRecycleHostedService starting — {Count} scheduler(s), tick interval = {Interval}", + _schedulers.Count, TickInterval); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(TickInterval, _timeProvider, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + + await TickOnceAsync(stoppingToken).ConfigureAwait(false); + } + + _logger.LogInformation("ScheduledRecycleHostedService stopping after {TickCount} tick(s).", TickCount); + } + + /// + /// Execute one scheduler tick against every registered scheduler. Factored out of the + /// loop so tests can drive it directly without needing to + /// synchronize with . + /// + public async Task TickOnceAsync(CancellationToken cancellationToken) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + TickCount++; + + foreach (var scheduler in _schedulers) + { + try + { + var fired = await scheduler.TickAsync(now, cancellationToken).ConfigureAwait(false); + if (fired) + _logger.LogInformation("Scheduled recycle fired at {Now:o}; next = {Next:o}", + now, scheduler.NextRecycleUtc); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + // A single scheduler fault must not take down the rest — log + continue. + _logger.LogError(ex, + "ScheduledRecycleScheduler tick failed at {Now:o}; continuing to other schedulers.", now); + } + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScheduledRecycleHostedServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScheduledRecycleHostedServiceTests.cs new file mode 100644 index 0000000..dea2800 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ScheduledRecycleHostedServiceTests.cs @@ -0,0 +1,152 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Stability; +using ZB.MOM.WW.OtOpcUa.Server.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +[Trait("Category", "Unit")] +public sealed class ScheduledRecycleHostedServiceTests +{ + private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc); + + private sealed class FakeClock : TimeProvider + { + public DateTime Utc { get; set; } = T0; + public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero); + } + + private sealed class FakeSupervisor : IDriverSupervisor + { + public string DriverInstanceId => "tier-c-fake"; + public int RecycleCount { get; private set; } + public Task RecycleAsync(string reason, CancellationToken cancellationToken) + { + RecycleCount++; + return Task.CompletedTask; + } + } + + private sealed class ThrowingSupervisor : IDriverSupervisor + { + public string DriverInstanceId => "tier-c-throws"; + public Task RecycleAsync(string reason, CancellationToken cancellationToken) + => throw new InvalidOperationException("supervisor unavailable"); + } + + [Fact] + public async Task TickOnce_BeforeInterval_DoesNotFire() + { + var clock = new FakeClock(); + var supervisor = new FakeSupervisor(); + var scheduler = new ScheduledRecycleScheduler( + DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor, + NullLogger.Instance); + + var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); + host.AddScheduler(scheduler); + + clock.Utc = T0.AddMinutes(1); + await host.TickOnceAsync(CancellationToken.None); + + supervisor.RecycleCount.ShouldBe(0); + host.TickCount.ShouldBe(1); + } + + [Fact] + public async Task TickOnce_AfterInterval_Fires() + { + var clock = new FakeClock(); + var supervisor = new FakeSupervisor(); + var scheduler = new ScheduledRecycleScheduler( + DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor, + NullLogger.Instance); + + var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); + host.AddScheduler(scheduler); + + clock.Utc = T0.AddMinutes(6); + await host.TickOnceAsync(CancellationToken.None); + + supervisor.RecycleCount.ShouldBe(1); + } + + [Fact] + public async Task TickOnce_MultipleTicks_AccumulateCount() + { + var clock = new FakeClock(); + var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); + + await host.TickOnceAsync(CancellationToken.None); + await host.TickOnceAsync(CancellationToken.None); + await host.TickOnceAsync(CancellationToken.None); + + host.TickCount.ShouldBe(3); + } + + [Fact] + public async Task AddScheduler_AfterStart_Throws() + { + var host = new ScheduledRecycleHostedService(NullLogger.Instance); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await host.StartAsync(cts.Token); // flips _started true even with cancelled token + await host.StopAsync(CancellationToken.None); + + var scheduler = new ScheduledRecycleScheduler( + DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, new FakeSupervisor(), + NullLogger.Instance); + + Should.Throw(() => host.AddScheduler(scheduler)); + } + + [Fact] + public async Task OneSchedulerThrowing_DoesNotStopOthers() + { + var clock = new FakeClock(); + var good = new FakeSupervisor(); + var bad = new ThrowingSupervisor(); + + var goodSch = new ScheduledRecycleScheduler( + DriverTier.C, TimeSpan.FromMinutes(5), T0, good, + NullLogger.Instance); + var badSch = new ScheduledRecycleScheduler( + DriverTier.C, TimeSpan.FromMinutes(5), T0, bad, + NullLogger.Instance); + + var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); + host.AddScheduler(badSch); + host.AddScheduler(goodSch); + + clock.Utc = T0.AddMinutes(6); + await host.TickOnceAsync(CancellationToken.None); + + good.RecycleCount.ShouldBe(1, "a faulting scheduler must not poison its neighbours"); + } + + [Fact] + public void SchedulerCount_MatchesAdded() + { + var host = new ScheduledRecycleHostedService(NullLogger.Instance); + var sup = new FakeSupervisor(); + host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, sup, NullLogger.Instance)); + host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(10), DateTime.UtcNow, sup, NullLogger.Instance)); + + host.SchedulerCount.ShouldBe(2); + } + + [Fact] + public async Task EmptyScheduler_List_TicksCleanly() + { + var clock = new FakeClock(); + var host = new ScheduledRecycleHostedService(NullLogger.Instance, clock); + + // No registered schedulers — tick is a no-op + counter still advances. + await host.TickOnceAsync(CancellationToken.None); + + host.TickCount.ShouldBe(1); + } +} -- 2.49.1