Phase 6.1 Stream B.4 follow-up — ScheduledRecycleHostedService drives registered schedulers on a fixed tick
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>
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user