[backend/cron] Add cron task statistics & drastically improve cron logic (ISH-760)
This commit is contained in:
parent
2f2ec826bc
commit
01cc7b08d9
4 changed files with 136 additions and 37 deletions
|
@ -303,10 +303,13 @@ public class AdminController(
|
|||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
public void RunCronTask([FromServices] CronService cronSvc, string id)
|
||||
{
|
||||
var task = cronSvc.Tasks.FirstOrDefault(p => p.GetType().FullName == id)
|
||||
var task = cronSvc.Tasks.FirstOrDefault(p => p.Task.GetType().FullName == id)
|
||||
?? throw GracefulException.NotFound("Task not found");
|
||||
|
||||
_ = cronSvc.RunCronTaskAsync(task);
|
||||
Task.Factory.StartNew(async () => await cronSvc.RunCronTaskAsync(task.Task, task.Trigger),
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
[HttpGet("policy")]
|
||||
|
|
|
@ -19,7 +19,7 @@ public static class TimeSpanExtensions
|
|||
if (timeSpan.Ticks < Minutes)
|
||||
{
|
||||
var minutes = (int)timeSpan.TotalMinutes;
|
||||
return minutes == 1 ? singleNumber ? "1 minute" : "minute" : $"{timeSpan.TotalMinutes} minutes";
|
||||
return minutes == 1 ? singleNumber ? "1 minute" : "minute" : $"{minutes} minutes";
|
||||
}
|
||||
|
||||
if (timeSpan.Ticks < Hours)
|
||||
|
@ -29,6 +29,6 @@ public static class TimeSpanExtensions
|
|||
}
|
||||
|
||||
var days = (int)timeSpan.TotalDays;
|
||||
return days == 1 ? singleNumber ? "1 day" : "day" : $"{timeSpan.TotalDays} days";
|
||||
return days == 1 ? singleNumber ? "1 day" : "day" : $"{days} days";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,18 +6,32 @@ namespace Iceshrimp.Backend.Core.Services;
|
|||
|
||||
public class CronService(IServiceScopeFactory serviceScopeFactory) : BackgroundService
|
||||
{
|
||||
public ICronTask[] Tasks { get; private set; } = [];
|
||||
public CronTaskState[] Tasks { get; private set; } = [];
|
||||
|
||||
public async Task RunCronTaskAsync(ICronTask task)
|
||||
public async Task RunCronTaskAsync(ICronTask task, ICronTrigger trigger)
|
||||
{
|
||||
if (trigger.IsRunning) return;
|
||||
trigger.IsRunning = true;
|
||||
trigger.Error = null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = serviceScopeFactory.CreateAsyncScope();
|
||||
await task.InvokeAsync(scope.ServiceProvider);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
trigger.Error = e;
|
||||
}
|
||||
|
||||
trigger.LastRun = DateTime.UtcNow;
|
||||
trigger.IsRunning = false;
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken token)
|
||||
{
|
||||
Tasks = PluginLoader
|
||||
.Assemblies.Prepend(Assembly.GetExecutingAssembly())
|
||||
var tasks = PluginLoader.Assemblies
|
||||
.Prepend(Assembly.GetExecutingAssembly())
|
||||
.SelectMany(AssemblyLoader.GetImplementationsOfInterface<ICronTask>)
|
||||
.OrderBy(p => p.AssemblyQualifiedName)
|
||||
.ThenBy(p => p.Name)
|
||||
|
@ -26,7 +40,9 @@ public class CronService(IServiceScopeFactory serviceScopeFactory) : BackgroundS
|
|||
.Cast<ICronTask>()
|
||||
.ToArray();
|
||||
|
||||
foreach (var task in Tasks)
|
||||
List<CronTaskState> stateObjs = [];
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
ICronTrigger trigger = task.Type switch
|
||||
{
|
||||
|
@ -35,23 +51,33 @@ public class CronService(IServiceScopeFactory serviceScopeFactory) : BackgroundS
|
|||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
|
||||
trigger.OnTrigger += async void () =>
|
||||
trigger.OnTrigger += async void (state) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunCronTaskAsync(task);
|
||||
await RunCronTaskAsync(task, state);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored (errors in the event handler crash the host process)
|
||||
}
|
||||
};
|
||||
|
||||
stateObjs.Add(new CronTaskState { Task = task, Trigger = trigger });
|
||||
}
|
||||
|
||||
Tasks = stateObjs.ToArray();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public class CronTaskState
|
||||
{
|
||||
public required ICronTask Task;
|
||||
public required ICronTrigger Trigger;
|
||||
}
|
||||
|
||||
public interface ICronTask
|
||||
{
|
||||
public TimeSpan Trigger { get; }
|
||||
|
@ -65,36 +91,59 @@ public enum CronTaskType
|
|||
Interval
|
||||
}
|
||||
|
||||
file interface ICronTrigger
|
||||
public interface ICronTrigger
|
||||
{
|
||||
public event Action? OnTrigger;
|
||||
public event Action<ICronTrigger>? OnTrigger;
|
||||
|
||||
public DateTime NextTrigger { get; }
|
||||
public DateTime? LastRun { get; set; }
|
||||
public bool IsRunning { get; set; }
|
||||
public Exception? Error { get; set; }
|
||||
|
||||
public TimeSpan UpdateNextTrigger();
|
||||
}
|
||||
|
||||
file class DailyTrigger : ICronTrigger, IDisposable
|
||||
public class DailyTrigger : ICronTrigger, IDisposable
|
||||
{
|
||||
public DailyTrigger(TimeSpan triggerTime, CancellationToken cancellationToken)
|
||||
{
|
||||
TriggerTime = triggerTime;
|
||||
CancellationToken = cancellationToken;
|
||||
NextTrigger = DateTime.UtcNow;
|
||||
|
||||
RunningTask = Task.Factory.StartNew(async () =>
|
||||
{
|
||||
while (!CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var nextTrigger = UpdateNextTrigger();
|
||||
await Task.Delay(nextTrigger, CancellationToken);
|
||||
OnTrigger?.Invoke(this);
|
||||
}
|
||||
}, CancellationToken, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
public TimeSpan UpdateNextTrigger()
|
||||
{
|
||||
var nextTrigger = DateTime.Today + TriggerTime - DateTime.Now;
|
||||
if (nextTrigger < TimeSpan.Zero)
|
||||
nextTrigger = nextTrigger.Add(new TimeSpan(24, 0, 0));
|
||||
await Task.Delay(nextTrigger, CancellationToken);
|
||||
OnTrigger?.Invoke();
|
||||
}
|
||||
}, CancellationToken, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
|
||||
NextTrigger = DateTime.UtcNow + nextTrigger;
|
||||
|
||||
return nextTrigger;
|
||||
}
|
||||
|
||||
private TimeSpan TriggerTime { get; }
|
||||
private CancellationToken CancellationToken { get; }
|
||||
private Task RunningTask { get; set; }
|
||||
|
||||
public event Action? OnTrigger;
|
||||
public event Action<ICronTrigger>? OnTrigger;
|
||||
|
||||
public DateTime NextTrigger { get; set; }
|
||||
public DateTime? LastRun { get; set; }
|
||||
public bool IsRunning { get; set; }
|
||||
|
||||
public Exception? Error { get; set; } = null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
@ -106,28 +155,49 @@ file class DailyTrigger : ICronTrigger, IDisposable
|
|||
~DailyTrigger() => Dispose();
|
||||
}
|
||||
|
||||
file class IntervalTrigger : ICronTrigger, IDisposable
|
||||
public class IntervalTrigger : ICronTrigger, IDisposable
|
||||
{
|
||||
public IntervalTrigger(TimeSpan triggerInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
TriggerInterval = triggerInterval;
|
||||
CancellationToken = cancellationToken;
|
||||
NextTrigger = DateTime.UtcNow + TriggerInterval;
|
||||
|
||||
RunningTask = Task.Factory.StartNew(async () =>
|
||||
{
|
||||
while (!CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
UpdateNextTrigger();
|
||||
await Task.Delay(TriggerInterval, CancellationToken);
|
||||
OnTrigger?.Invoke();
|
||||
|
||||
while (LastRun != null && LastRun + TriggerInterval + TimeSpan.FromMinutes(5) < DateTime.UtcNow)
|
||||
{
|
||||
NextTrigger = LastRun.Value + TriggerInterval;
|
||||
await Task.Delay(NextTrigger - DateTime.UtcNow);
|
||||
}
|
||||
|
||||
OnTrigger?.Invoke(this);
|
||||
}
|
||||
}, CancellationToken, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
public TimeSpan UpdateNextTrigger()
|
||||
{
|
||||
NextTrigger = DateTime.UtcNow + TriggerInterval;
|
||||
return TriggerInterval;
|
||||
}
|
||||
|
||||
private TimeSpan TriggerInterval { get; }
|
||||
private CancellationToken CancellationToken { get; }
|
||||
private Task RunningTask { get; set; }
|
||||
|
||||
public event Action? OnTrigger;
|
||||
public event Action<ICronTrigger>? OnTrigger;
|
||||
|
||||
public DateTime NextTrigger { get; set; }
|
||||
public DateTime? LastRun { get; set; }
|
||||
public bool IsRunning { get; set; }
|
||||
|
||||
public Exception? Error { get; set; } = null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
|
|
@ -6,30 +6,56 @@
|
|||
@inject CronService CronSvc
|
||||
<AdminPageHeader Title="Cron tasks"/>
|
||||
|
||||
<table>
|
||||
<table class="auto-table">
|
||||
<thead>
|
||||
<th>Name</th>
|
||||
<th>Assembly</th>
|
||||
<th>Schedule</th>
|
||||
<th>Last run</th>
|
||||
<th>Next run</th>
|
||||
<th>Actions</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var task in CronSvc.Tasks)
|
||||
{
|
||||
var type = task.GetType();
|
||||
var schedule = task.Type switch
|
||||
var type = task.Task.GetType();
|
||||
var schedule = task.Task.Type switch
|
||||
{
|
||||
CronTaskType.Daily when task.Trigger == TimeSpan.Zero => "daily at midnight",
|
||||
CronTaskType.Daily when task.Task.Trigger == TimeSpan.Zero => "daily at midnight",
|
||||
//
|
||||
CronTaskType.Daily => $"daily at {((int)task.Trigger.TotalHours).ToString().PadLeft(2, '0')}:{((int)task.Trigger.TotalMinutes).ToString().PadLeft(2, '0')}",
|
||||
CronTaskType.Interval => $"every {task.Trigger.ToDisplayString(singleNumber: false)}",
|
||||
CronTaskType.Daily => $"daily at {((int)task.Task.Trigger.TotalHours).ToString().PadLeft(2, '0')}:{((int)task.Task.Trigger.TotalMinutes).ToString().PadLeft(2, '0')}",
|
||||
CronTaskType.Interval => $"every {task.Task.Trigger.ToDisplayString(singleNumber: false)}",
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
|
||||
var last = task.Trigger.IsRunning
|
||||
? "now"
|
||||
: task.Trigger.LastRun.HasValue
|
||||
? (DateTime.UtcNow - task.Trigger.LastRun.Value).ToDisplayString() + " ago"
|
||||
: "never";
|
||||
|
||||
if (task.Trigger.LastRun.HasValue)
|
||||
{
|
||||
last += task.Trigger.Error is null ? " (OK)" : " (Error)";
|
||||
}
|
||||
|
||||
var next = task.Trigger.IsRunning
|
||||
? "now"
|
||||
: "in " + (task.Trigger.NextTrigger - DateTime.UtcNow).ToDisplayString();
|
||||
|
||||
<tr>
|
||||
<td>@type.Name</td>
|
||||
<td>@type.Assembly.GetName().Name</td>
|
||||
<td>@schedule</td>
|
||||
@if (task.Trigger is { LastRun: not null, Error: { } e })
|
||||
{
|
||||
<td title="@e.ToString()">@last</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td>@last</td>
|
||||
}
|
||||
<td>@next</td>
|
||||
<td><a class="fake-link" onclick="runCronTask('@type.FullName', event.target)">Run now</a></td>
|
||||
</tr>
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue