using Cronos; using FrameworkBLL.DataSync; using FrameworkDAL.CustomCode.DataSync; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace FrameworkSL.Jobs { // Polls TDSYNCJOB every 60 seconds for enabled jobs whose cron has elapsed. // Uses Cronos to evaluate TSCHEDULER.CRONEXPRESSION against the last run time. // Follows the CashbackPayoutJob pattern (BackgroundService + PeriodicTimer + IServiceScopeFactory). public sealed class DataSyncSchedulerJob : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly IConfiguration _config; private readonly ILogger _logger; public DataSyncSchedulerJob( IServiceScopeFactory scopeFactory, IConfiguration config, ILogger logger) { _scopeFactory = scopeFactory; _config = config; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { int intervalSecs = _config.GetValue("DataSync:SchedulerPollIntervalSeconds", 60); _logger.LogInformation("DataSyncSchedulerJob starting — polling every {Seconds}s", intervalSecs); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(intervalSecs)); try { // The await is in the loop condition itself: when stoppingToken is cancelled // (host shutdown) while WaitForNextTickAsync is pending, it throws instead of // returning false, so it must be caught out here — a catch inside the loop // body never sees it. while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) { try { await ProcessDueJobsAsync(stoppingToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "DataSyncSchedulerJob tick failed"); } } } catch (OperationCanceledException) { // Expected on graceful shutdown — not an error. _logger.LogInformation("DataSyncSchedulerJob stopping (shutdown requested)."); } } // ── Private ─────────────────────────────────────────────────────────── private async Task ProcessDueJobsAsync(CancellationToken ct) { using var scope = _scopeFactory.CreateScope(); var jobDal = scope.ServiceProvider.GetRequiredService(); var runBll = scope.ServiceProvider.GetRequiredService(); var login = BuildSystemLogin(); var jobs = await jobDal.GetEnabledJobsForScheduler(login, ct).ConfigureAwait(false); var now = DateTime.UtcNow; foreach (var job in jobs) { if (string.IsNullOrWhiteSpace(job.SchedulerCronExpression)) continue; if (!IsDue(job.SchedulerCronExpression, job.LastRunAt, now)) continue; _logger.LogInformation("DataSyncSchedulerJob triggering SyncJobId {SyncJobId}", job.SyncJobId); try { // ExecuteSyncJobAsync records the run log and fires the actual sync in a // background Task with its own DI scope — this await just gets the RunId back. await runBll.ExecuteSyncJobAsync(job.SyncJobId, triggeredBy: 0, triggeredByUserId: null, login, ct).ConfigureAwait(false); } catch (InvalidOperationException ioe) when (ioe.Message.Contains("already running")) { // Concurrency guard fired — job already running, skip } catch (Exception ex) { _logger.LogError(ex, "Failed to trigger SyncJobId {SyncJobId}", job.SyncJobId); } } } private static bool IsDue(string cronExpr, DateTime? lastRunAt, DateTime nowUtc) { try { var cron = CronExpression.Parse(cronExpr, CronFormat.IncludeSeconds); var baseline = lastRunAt ?? DateTime.MinValue; var next = cron.GetNextOccurrence(baseline, TimeZoneInfo.Utc); return next.HasValue && next.Value <= nowUtc; } catch { return false; } } private LoginDTO BuildSystemLogin() => new() { ClientId = _config.GetValue("DataSync:SystemTenantId", -1), DatabaseName = _config["DataSync:SystemDatabaseName"] ?? "GoodBooks_Main", UserId = -1 }; } }