Last active
July 26, 2023 14:59
-
-
Save michaellwest/68b367c2c479ae7b79b0a3a1f74cb546 to your computer and use it in GitHub Desktop.
Precision scheduling for Sitecore using Hangfire. Replaces the out of the box Scheduled Task feature.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$connection = [Hangfire.JobStorage]::Current.GetConnection() | |
$recurringJobs = [Hangfire.Storage.StorageConnectionExtensions]::GetRecurringJobs($connection) | |
$props = @{ | |
Title = "Hangfire Recurring Jobs" | |
InfoTitle = "Recurring Jobs Report" | |
InfoDescription = "This report provides details on the currently scheduled recurring jobs." | |
PageSize = 25 | |
Property = @( | |
"Id", | |
@{Label="Task"; Expression={Get-Item -Path "master:" -ID $_.ID | Select-Object -ExpandProperty Name}}, | |
"Cron", | |
@{Label="NextExecution (Local)"; Expression={$_.NextExecution.ToLocalTime()} }, | |
"LastJobState", | |
@{Label="LastExecution (Local)"; Expression={$_.LastExecution.ToLocalTime()} }, | |
"TimeZoneId", | |
"Error", | |
"RetryAttempt" | |
) | |
} | |
$recurringJobs | Show-ListView @props | |
Close-Window |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8" ?> | |
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/"> | |
<sitecore role:require="Standalone or ContentManagement"> | |
<pipelines> | |
<owin.initialize> | |
<processor type="Scms.Foundation.Scheduling.Pipelines.PrecisionScheduler, Scms.Foundation"> | |
<StartupDelaySeconds>120</StartupDelaySeconds> | |
<RefreshSchedule>*/2 * * * *</RefreshSchedule> | |
</processor> | |
</owin.initialize> | |
</pipelines> | |
<scheduling> | |
<!-- Replaced by the PrecisionScheduler --> | |
<agent name="Master_Database_Agent"> | |
<patch:attribute name="interval" value="00:00:00" /> | |
</agent> | |
</scheduling> | |
</sitecore> | |
</configuration> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using Hangfire; | |
using Hangfire.MemoryStorage; | |
using Hangfire.Storage; | |
using Microsoft.Extensions.DependencyInjection; | |
using Sitecore; | |
using Sitecore.Abstractions; | |
using Sitecore.Data; | |
using Sitecore.Data.Items; | |
using Sitecore.DependencyInjection; | |
using Sitecore.Diagnostics; | |
using Sitecore.Jobs; | |
using Sitecore.Owin.Pipelines.Initialize; | |
using Sitecore.Tasks; | |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Text.RegularExpressions; | |
namespace Scms.Foundation.Scheduling.Pipelines | |
{ | |
public class PrecisionScheduler : InitializeProcessor | |
{ | |
private const string SCHEDULE_DATABASE = "master"; | |
public string RefreshSchedule { get; set; } = "*/2 * * * *"; | |
public int StartupDelaySeconds { get; set; } = 15; | |
private static void LogMessage(string message) | |
{ | |
Log.Info($"[PrecisionScheduler] {message}", nameof(PrecisionScheduler)); | |
} | |
public override void Process(InitializeArgs args) | |
{ | |
var app = args.App; | |
app.UseHangfireAspNet(() => | |
{ | |
GlobalConfiguration.Configuration.UseMemoryStorage(); | |
return new[] { new BackgroundJobServer() }; | |
}); | |
LogMessage("Starting up precision scheduler."); | |
BackgroundJob.Schedule(() => Initialize(RefreshSchedule), TimeSpan.FromSeconds(StartupDelaySeconds)); | |
} | |
private static string GenerateMultiDayCronExpression(TimeSpan runTime, List<DayOfWeek> daysToRun) | |
{ | |
var castedDaysToRun = daysToRun.Cast<int>().ToList(); | |
return $"{ParseCronTimeSpan(runTime)} * * {ParseMultiDaysList(castedDaysToRun)}"; | |
} | |
private static string ParseCronTimeSpan(TimeSpan timeSpan) | |
{ | |
if (timeSpan.Days > 0) | |
{ | |
//At HH:mm every day. | |
return $"{timeSpan.Minutes} {timeSpan.Hours}"; | |
} | |
else if (timeSpan.Hours > 0) | |
{ | |
//At m minutes past the hour, every h hours. | |
return $"{timeSpan.Minutes} */{timeSpan.Hours}"; | |
} | |
else if (timeSpan.Minutes > 0) | |
{ | |
//Every m minutes. | |
return $"*/{timeSpan.Minutes} *"; | |
} | |
return $"*/30 *"; | |
} | |
private static string ParseMultiDaysList(List<int> daysToRun) | |
{ | |
if (daysToRun.Any() && daysToRun.Count == 7) return "*"; | |
return string.Join(",", daysToRun); | |
} | |
private static List<DayOfWeek> ParseDays(int days) | |
{ | |
var daysOfWeek = new List<DayOfWeek>(); | |
if (days <= 0) return daysOfWeek; | |
if (MainUtil.IsBitSet((int)DaysOfWeek.Sunday, days)) | |
{ | |
daysOfWeek.Add(DayOfWeek.Sunday); | |
} | |
if (MainUtil.IsBitSet((int)DaysOfWeek.Monday, days)) | |
{ | |
daysOfWeek.Add(DayOfWeek.Monday); | |
} | |
if (MainUtil.IsBitSet((int)DaysOfWeek.Tuesday, days)) | |
{ | |
daysOfWeek.Add(DayOfWeek.Tuesday); | |
} | |
if (MainUtil.IsBitSet((int)DaysOfWeek.Wednesday, days)) | |
{ | |
daysOfWeek.Add(DayOfWeek.Wednesday); | |
} | |
if (MainUtil.IsBitSet((int)DaysOfWeek.Thursday, days)) | |
{ | |
daysOfWeek.Add(DayOfWeek.Thursday); | |
} | |
if (MainUtil.IsBitSet((int)DaysOfWeek.Friday, days)) | |
{ | |
daysOfWeek.Add(DayOfWeek.Friday); | |
} | |
if (MainUtil.IsBitSet((int)DaysOfWeek.Saturday, days)) | |
{ | |
daysOfWeek.Add(DayOfWeek.Saturday); | |
} | |
return daysOfWeek; | |
} | |
public static void RunSchedule(ID itemId) | |
{ | |
var database = ServiceLocator.ServiceProvider.GetRequiredService<BaseFactory>().GetDatabase("master", true); | |
var item = database.GetItem(itemId); | |
if (item == null) | |
{ | |
LogMessage($"Removing background job for {itemId}."); | |
RecurringJob.RemoveIfExists(itemId.ToString()); | |
return; | |
} | |
var jobName = $"{nameof(PrecisionScheduler)}-{itemId}"; | |
var runningJob = JobManager.GetJob(jobName); | |
if(runningJob != null && runningJob.Status.State == JobState.Running) | |
{ | |
LogMessage($"Background job for {itemId} is already running."); | |
return; | |
} | |
LogMessage($"Running background job for {itemId}."); | |
var scheduleItem = new ScheduleItem(item); | |
var jobOptions = new DefaultJobOptions(jobName, "scheduling", "scheduler", Activator.CreateInstance(typeof(JobRunner)), "Run", new object[] { ID.Parse(itemId) }); | |
JobManager.Start(jobOptions); | |
} | |
public static void Initialize(string refreshSchedule) | |
{ | |
ManageJobs(true); | |
RecurringJob.AddOrUpdate(nameof(ManageJobs), () => ManageJobs(false), refreshSchedule); | |
} | |
public static void ManageJobs(bool isStartup) | |
{ | |
var database = ServiceLocator.ServiceProvider.GetRequiredService<BaseFactory>().GetDatabase(SCHEDULE_DATABASE, true); | |
var descendants = database.SelectItems($"/sitecore/system/tasks/schedules//*[@@templateid='{TemplateIDs.Schedule}']"); | |
var schedules = new Dictionary<string, string>(); | |
foreach (var item in descendants) | |
{ | |
if (item.TemplateID != TemplateIDs.Schedule) continue; | |
var itemId = item.ID.ToString(); | |
var schedule = GetSchedule(item); | |
if (string.IsNullOrEmpty(schedule)) continue; | |
schedules.Add(itemId, schedule); | |
} | |
var jobs = JobStorage.Current.GetConnection().GetRecurringJobs(); | |
var existingJobs = new List<string>(); | |
foreach (var job in jobs) | |
{ | |
if (!ID.IsID(job.Id)) continue; | |
var itemId = job.Id; | |
if (!schedules.ContainsKey(itemId)) | |
{ | |
LogMessage($"Removing {itemId} from recurring schedule."); | |
RecurringJob.RemoveIfExists(itemId); | |
continue; | |
} | |
var item = database.GetItem(itemId); | |
var schedule = GetSchedule(item); | |
if (string.IsNullOrEmpty(schedule)) | |
{ | |
LogMessage($"Removing {itemId} from recurring schedule with invalid expression."); | |
RecurringJob.RemoveIfExists(itemId); | |
continue; | |
} | |
if (!string.Equals(job.Cron, schedule, StringComparison.InvariantCultureIgnoreCase)) | |
{ | |
LogMessage($"Updating {itemId} with a new schedule '{schedule}'."); | |
RecurringJob.AddOrUpdate($"{itemId}", () => RunSchedule(ID.Parse(itemId)), schedule, TimeZoneInfo.Local); | |
} | |
existingJobs.Add(itemId); | |
} | |
var missingJobs = schedules.Keys.Except(existingJobs); | |
foreach (var missingJob in missingJobs) | |
{ | |
var itemId = missingJob; | |
var item = database.GetItem(itemId); | |
var schedule = GetSchedule(item); | |
LogMessage($"Registering recurring job for {itemId} with schedule '{schedule}'."); | |
RecurringJob.AddOrUpdate($"{itemId}", () => RunSchedule(ID.Parse(itemId)), schedule, TimeZoneInfo.Local); | |
} | |
if (isStartup) | |
{ | |
var recurringJobs = JobStorage.Current.GetConnection().GetRecurringJobs(); | |
if (recurringJobs == null) return; | |
foreach (var recurringJob in recurringJobs) | |
{ | |
if (!ID.IsID(recurringJob.Id)) continue; | |
var itemId = recurringJob.Id; | |
if (!schedules.ContainsKey(itemId)) continue; | |
var item = database.GetItem(itemId); | |
var scheduleItem = new ScheduleItem(item); | |
var missedLastRun = (recurringJob.NextExecution - scheduleItem.LastRun) > TimeSpan.FromHours(24); | |
if (missedLastRun) | |
{ | |
LogMessage($"Running missed job {itemId}."); | |
var jobName = $"{nameof(PrecisionScheduler)}-{itemId}"; | |
var jobOptions = new DefaultJobOptions(jobName, "scheduling", "scheduler", Activator.CreateInstance(typeof(JobRunner)), "Run", new object[] { ID.Parse(itemId) }); | |
JobManager.Start(jobOptions); | |
} | |
} | |
} | |
} | |
private static string GetSchedule(Item item) | |
{ | |
var schedule = item.Fields[ScheduleFieldIDs.Schedule].Value; | |
if (string.IsNullOrEmpty(schedule)) return string.Empty; | |
if (Regex.IsMatch(schedule, @"^(((\d+,)+\d+|(\d+|\*(\/|-)\d+)|\d+|\*)\s?){5,7}$", RegexOptions.Compiled)) | |
{ | |
return schedule; | |
} | |
var recurrence = new Recurrence(schedule); | |
if (recurrence.Days == DaysOfWeek.None || | |
recurrence.Interval == TimeSpan.Zero || | |
recurrence.InRange(DateTime.UtcNow) != true) return string.Empty; | |
return GenerateMultiDayCronExpression(recurrence.Interval, ParseDays((int)recurrence.Days).ToList()); | |
} | |
} | |
public class JobRunner | |
{ | |
public void Run(ID itemId) | |
{ | |
var database = ServiceLocator.ServiceProvider.GetRequiredService<BaseFactory>().GetDatabase("master", true); | |
var item = database.GetItem(itemId); | |
if (item == null) return; | |
var scheduleItem = new ScheduleItem(item); | |
scheduleItem.Execute(); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment