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<ScheduledRecycleHostedService>(); builder.Services.AddHostedService(sp => sp.GetRequiredService<ScheduledRecycleHostedService>()); // 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) <noreply@anthropic.com>
118 lines
4.9 KiB
C#
118 lines
4.9 KiB
C#
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
|
|
|
/// <summary>
|
|
/// Drives one or more <see cref="ScheduledRecycleScheduler"/> 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Registered as a singleton in Program.cs. Each Tier C driver instance that wants a
|
|
/// scheduled recycle registers its scheduler via
|
|
/// <see cref="AddScheduler(ScheduledRecycleScheduler)"/> at startup. The hosted service
|
|
/// wakes every <see cref="TickInterval"/> (default 1 min) and calls
|
|
/// <see cref="ScheduledRecycleScheduler.TickAsync"/> on each registered scheduler.</para>
|
|
///
|
|
/// <para>Scheduler registration is closed after <see cref="ExecuteAsync"/> 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.</para>
|
|
/// </remarks>
|
|
public sealed class ScheduledRecycleHostedService : BackgroundService
|
|
{
|
|
private readonly List<ScheduledRecycleScheduler> _schedulers = [];
|
|
private readonly ILogger<ScheduledRecycleHostedService> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
private bool _started;
|
|
|
|
/// <summary>How often <see cref="ScheduledRecycleScheduler.TickAsync"/> fires on each registered scheduler.</summary>
|
|
public TimeSpan TickInterval { get; }
|
|
|
|
public ScheduledRecycleHostedService(
|
|
ILogger<ScheduledRecycleHostedService> logger,
|
|
TimeProvider? timeProvider = null,
|
|
TimeSpan? tickInterval = null)
|
|
{
|
|
_logger = logger;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
TickInterval = tickInterval ?? TimeSpan.FromMinutes(1);
|
|
}
|
|
|
|
/// <summary>Register a scheduler to drive. Must be called before the host starts.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Snapshot of the current tick count — diagnostics only.</summary>
|
|
public int TickCount { get; private set; }
|
|
|
|
/// <summary>Snapshot of the number of registered schedulers — diagnostics only.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Execute one scheduler tick against every registered scheduler. Factored out of the
|
|
/// <see cref="ExecuteAsync"/> loop so tests can drive it directly without needing to
|
|
/// synchronize with <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)"/>.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|