using Microsoft.Extensions.Logging; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Infrastructure.Persistence; namespace SharepointToolbox.Web.Services.Reports; /// /// Background scheduler. Every it loads the schedule /// definitions, runs any whose is due, and /// advances their next-run stamp. Each run executes in its own DI scope (report /// services are scoped). Due schedules run sequentially within a tick to bound the /// load a single tenant sees; a long run simply delays the next due check. /// public class ScheduledReportHostedService : BackgroundService { private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1); private readonly IServiceScopeFactory _scopeFactory; private readonly ScheduledReportRepository _repo; private readonly ScheduledRunCoordinator _coordinator; private readonly ILogger _log; public ScheduledReportHostedService( IServiceScopeFactory scopeFactory, ScheduledReportRepository repo, ScheduledRunCoordinator coordinator, ILogger log) { _scopeFactory = scopeFactory; _repo = repo; _coordinator = coordinator; _log = log; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _log.LogInformation("Scheduled report service started (tick {Interval}).", TickInterval); using var timer = new PeriodicTimer(TickInterval); // Tick once at startup, then on every interval. do { try { await TickAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { _log.LogError(ex, "Scheduled report tick failed."); } } while (await WaitAsync(timer, stoppingToken)); _log.LogInformation("Scheduled report service stopping."); } private static async Task WaitAsync(PeriodicTimer timer, CancellationToken ct) { try { return await timer.WaitForNextTickAsync(ct); } catch (OperationCanceledException) { return false; } } private async Task TickAsync(CancellationToken ct) { // Global pause: an admin has suspended all cadence-triggered runs. In-flight // runs are unaffected (those are stopped individually); due schedules simply // wait — NextRun is not advanced, so they fire once resumed. if (_coordinator.IsPaused) return; var now = DateTime.UtcNow; var schedules = await _repo.LoadAsync(); foreach (var schedule in schedules) { ct.ThrowIfCancellationRequested(); if (!schedule.Enabled) continue; // First time we see an enabled schedule with no next-run: arm it, don't run. if (schedule.NextRunUtc is null) { schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now); await _repo.UpsertAsync(schedule); continue; } if (schedule.NextRunUtc > now) continue; await RunOneAsync(schedule, now, ct); } } private async Task RunOneAsync(ScheduledReport schedule, DateTime now, CancellationToken ct) { // Register the run so the UI can stop it mid-flight. The returned token trips on // either app shutdown (ct) or an admin Stop. Null = a run is already in progress // (e.g. a long previous run or a "Run now"); skip without advancing so it retries. var token = _coordinator.TryBegin(schedule.Id, ct); if (token is null) { _log.LogWarning("Schedule '{Name}' ({Id}) still running; skipping this tick.", schedule.Name, schedule.Id); return; } try { using var scope = _scopeFactory.CreateScope(); var runner = scope.ServiceProvider.GetRequiredService(); await runner.RunAsync(schedule, token.Value); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; // app shutdown — bubble up to stop the service loop } catch (OperationCanceledException) { // Stopped via the coordinator (admin Stop / Stop all), not shutdown. Not a failure. _log.LogInformation("Schedule '{Name}' ({Id}) was stopped.", schedule.Name, schedule.Id); } catch (Exception ex) { // RunAsync already captures report-level failures; this guards anything // thrown outside it so one bad schedule can't stop the others advancing. _log.LogError(ex, "Schedule '{Name}' ({Id}) failed to run.", schedule.Name, schedule.Id); } finally { _coordinator.Complete(schedule.Id); // Advance regardless of outcome so a persistently failing (or stopped) // schedule doesn't hot-loop every tick. schedule.LastRunUtc = now; schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now); await _repo.UpsertAsync(schedule); } } }