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); } } } }