using System.Collections.Concurrent; using Cleanuparr.Infrastructure.Models; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Infrastructure.Utilities; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using Quartz; using Quartz.Impl.Matchers; using Quartz.Spi; namespace Cleanuparr.Infrastructure.Services; public class JobManagementService : IJobManagementService { private readonly ILogger _logger; private readonly ISchedulerFactory _schedulerFactory; private readonly IHubContext _hubContext; private readonly ConcurrentDictionary _jobKeys = new(); public JobManagementService(ILogger logger, ISchedulerFactory schedulerFactory, IHubContext hubContext) { _logger = logger; _schedulerFactory = schedulerFactory; _hubContext = hubContext; } public async Task StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null) { string jobName = jobType.ToString(); string? cronExpression = null; // Validate and set the cron expression if (directCronExpression != null) { // Validate direct cron expression if (!CronExpressionConverter.IsValidCronExpression(directCronExpression)) { _logger.LogError("Invalid cron expression: {cronExpression}", directCronExpression); return false; } cronExpression = directCronExpression; } else if (schedule != null) { try { // Validate schedule and get cron expression schedule.Validate(); cronExpression = schedule.ToCronExpression(); } catch (Exception ex) { _logger.LogError(ex, "Invalid job schedule"); return false; } } try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobName); // Check if job exists, create it if it doesn't if (!await scheduler.CheckExists(jobKey)) { _logger.LogError("Job {name} does not exist in scheduler. " + "Jobs should be created at startup by BackgroundJobManager.", jobName); return false; } // Store the job key for later use _jobKeys.TryAdd(jobName, jobKey); // Clean up all existing triggers for this job first await CleanupAllTriggersForJob(scheduler, jobKey); // If cron expression is provided, create and schedule the main trigger if (!string.IsNullOrEmpty(cronExpression)) { var triggerKey = new TriggerKey($"{jobName}-trigger"); var newTrigger = TriggerBuilder.Create() .WithIdentity(triggerKey) .ForJob(jobKey) .WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing()) .Build(); await scheduler.ScheduleJob(newTrigger); // Compute next fire time for logging IReadOnlyList nextFireTimes = TriggerUtils.ComputeFireTimes((IOperableTrigger)newTrigger, null, 1); _logger.LogInformation("Job {name} scheduled with cron expression '{cronExpression}', next run at {nextRunTime}", jobName, cronExpression, nextFireTimes.FirstOrDefault().LocalDateTime); // Optionally trigger immediate execution for startup // await TriggerJobImmediately(scheduler, jobKey, "startup"); } else { // If no cron expression, create a one-time trigger to run now var oneTimeTrigger = TriggerBuilder.Create() .WithIdentity($"{jobName}-onetime-trigger") .ForJob(jobKey) .StartNow() .Build(); await scheduler.ScheduleJob(oneTimeTrigger); _logger.LogInformation("Job {name} scheduled for immediate one-time execution", jobName); } // Resume the job if it's paused await scheduler.ResumeJob(jobKey); _logger.LogInformation("Job {name} started successfully", jobName); return true; } catch (Exception ex) { _logger.LogError(ex, "Error starting job {jobName}", jobName); return false; } } /// /// Cleans up all existing triggers for a job to ensure a clean state /// private async Task CleanupAllTriggersForJob(IScheduler scheduler, JobKey jobKey) { try { var existingTriggers = await scheduler.GetTriggersOfJob(jobKey); foreach (var trigger in existingTriggers) { await scheduler.UnscheduleJob(trigger.Key); _logger.LogDebug("Removed existing trigger {triggerKey} for job {jobKey}", trigger.Key.Name, jobKey.Name); } if (existingTriggers.Any()) { _logger.LogDebug("Cleaned up {count} existing triggers for job {jobName}", existingTriggers.Count, jobKey.Name); } } catch (Exception ex) { _logger.LogWarning(ex, "Error cleaning up triggers for job {jobName}", jobKey.Name); } } /// /// Triggers a job immediately with a one-time trigger /// private async Task TriggerJobImmediately(IScheduler scheduler, JobKey jobKey, string reason) { try { var immediateTrigger = TriggerBuilder.Create() .WithIdentity($"{jobKey.Name}-immediate-{reason}-{DateTimeOffset.UtcNow.Ticks}") .ForJob(jobKey) .StartNow() .Build(); await scheduler.ScheduleJob(immediateTrigger); _logger.LogDebug("Triggered job {jobName} immediately for reason: {reason}", jobKey.Name, reason); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name); } } /// /// Gets the main scheduled trigger for a job (excludes one-time triggers) /// public async Task GetMainTrigger(JobType jobType) { string jobName = jobType.ToString(); try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobName); if (!await scheduler.CheckExists(jobKey)) { return null; } // Look for the main trigger (follows our naming convention) var mainTriggerKey = new TriggerKey($"{jobName}-trigger"); return await scheduler.GetTrigger(mainTriggerKey); } catch (Exception ex) { _logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName); return null; } } public async Task StopJob(JobType jobType) { string jobName = jobType.ToString(); try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobName); if (!await scheduler.CheckExists(jobKey)) { _logger.LogError("Job {name} does not exist", jobName); return false; } // Clean up all triggers for this job (reuse our centralized method) await CleanupAllTriggersForJob(scheduler, jobKey); _logger.LogInformation("Job {name} stopped successfully", jobName); return true; } catch (Exception ex) { _logger.LogError(ex, "Error stopping job {jobName}", jobName); return false; } } public async Task> GetAllJobs(IScheduler? scheduler = null) { try { scheduler ??= await _schedulerFactory.GetScheduler(); var result = new List(); var jobGroups = await scheduler.GetJobGroupNames(); foreach (var group in jobGroups) { var jobKeys = await scheduler.GetJobKeys(GroupMatcher.GroupEquals(group)); foreach (var jobKey in jobKeys) { var jobDetail = await scheduler.GetJobDetail(jobKey); var triggers = await scheduler.GetTriggersOfJob(jobKey); var jobInfo = new JobInfo { Name = jobKey.Name, JobType = jobKey.Name, // Use the job key name instead of generic type Status = "Not Scheduled" }; if (triggers.Any()) { var trigger = triggers.First(); var triggerState = await scheduler.GetTriggerState(trigger.Key); jobInfo.Status = triggerState switch { TriggerState.Normal => "Scheduled", TriggerState.Paused => "Paused", TriggerState.Complete => "Complete", TriggerState.Error => "Error", TriggerState.Blocked => "Running", TriggerState.None => "Not Scheduled", _ => "Unknown" }; if (trigger is ICronTrigger cronTrigger) { jobInfo.Schedule = cronTrigger.CronExpressionString; } jobInfo.NextRunTime = trigger.GetNextFireTimeUtc()?.UtcDateTime; jobInfo.PreviousRunTime = trigger.GetPreviousFireTimeUtc()?.UtcDateTime; } result.Add(jobInfo); } } return result; } catch (Exception ex) { _logger.LogError(ex, "Error getting all jobs"); return new List(); } } public async Task GetJob(JobType jobType) { string jobName = jobType.ToString(); try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobName); if (!await scheduler.CheckExists(jobKey)) { _logger.LogError("Job {name} does not exist", jobName); return new JobInfo { Name = jobName, Status = "Not Found" }; } var jobDetail = await scheduler.GetJobDetail(jobKey); var triggers = await scheduler.GetTriggersOfJob(jobKey); var jobInfo = new JobInfo { Name = jobName, JobType = jobName, // Use the job key name instead of generic type Status = "Not Scheduled" }; if (triggers.Any()) { var trigger = triggers.First(); var state = await scheduler.GetTriggerState(trigger.Key); jobInfo.Status = state switch { TriggerState.Normal => "Scheduled", // Normal means trigger is scheduled and ready to fire TriggerState.Paused => "Paused", TriggerState.Complete => "Complete", TriggerState.Error => "Error", TriggerState.Blocked => "Running", // Blocked typically means job is currently executing TriggerState.None => "Not Scheduled", _ => "Unknown" }; if (trigger is ICronTrigger cronTrigger) { jobInfo.Schedule = cronTrigger.CronExpressionString; } jobInfo.NextRunTime = trigger.GetNextFireTimeUtc()?.LocalDateTime; jobInfo.PreviousRunTime = trigger.GetPreviousFireTimeUtc()?.LocalDateTime; } return jobInfo; } catch (Exception ex) { _logger.LogError(ex, "Error getting job {name}", jobName); return new JobInfo { Name = jobName, Status = "Error" }; } } public async Task TriggerJobOnce(JobType jobType) { string jobName = jobType.ToString(); try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobName); if (!await scheduler.CheckExists(jobKey)) { _logger.LogError("Job {name} does not exist", jobName); return false; } await TriggerJobImmediately(scheduler, jobKey, "manual"); _logger.LogInformation("Job {name} triggered for one-time execution", jobName); return true; } catch (Exception ex) { _logger.LogError(ex, "Error triggering job {jobName}", jobName); return false; } } public async Task UpdateJobSchedule(JobType jobType, JobSchedule schedule) { if (schedule == null) throw new ArgumentNullException(nameof(schedule)); string jobName = jobType.ToString(); string cronExpression = schedule.ToCronExpression(); try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobName); if (!await scheduler.CheckExists(jobKey)) { _logger.LogError("Job {name} does not exist", jobName); return false; } // Clean up all existing triggers for this job await CleanupAllTriggersForJob(scheduler, jobKey); // Create new trigger with consistent naming var triggerKey = new TriggerKey($"{jobName}-trigger"); var newTrigger = TriggerBuilder.Create() .WithIdentity(triggerKey) .ForJob(jobKey) .WithCronSchedule(cronExpression, x => x.WithMisfireHandlingInstructionDoNothing()) .Build(); await scheduler.ScheduleJob(newTrigger); _logger.LogInformation("Job {name} schedule updated successfully to {cronExpression}", jobName, cronExpression); return true; } catch (Exception ex) { _logger.LogError(ex, "Error updating job {name} schedule", jobName); return false; } } }