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