mirror of
https://github.com/LBPUnion/ProjectLighthouse.git
synced 2025-07-16 18:21:28 +00:00
Refactor RepeatingTaskHandler (#796)
* Refactor RepeatingTaskHandler into an ASP.NET service * Add unit tests for RepeatingTaskService * Make repeating task unit tests work independent of time * Fix weird behavior when task is canceled
This commit is contained in:
parent
9ac8a166d4
commit
a0d021f1e2
7 changed files with 213 additions and 69 deletions
|
@ -1,4 +1,5 @@
|
|||
using System.Net;
|
||||
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
||||
using LBPUnion.ProjectLighthouse.Configuration;
|
||||
using LBPUnion.ProjectLighthouse.Database;
|
||||
using LBPUnion.ProjectLighthouse.Logging;
|
||||
|
@ -6,6 +7,7 @@ using LBPUnion.ProjectLighthouse.Mail;
|
|||
using LBPUnion.ProjectLighthouse.Middlewares;
|
||||
using LBPUnion.ProjectLighthouse.Serialization;
|
||||
using LBPUnion.ProjectLighthouse.Servers.GameServer.Middlewares;
|
||||
using LBPUnion.ProjectLighthouse.Services;
|
||||
using LBPUnion.ProjectLighthouse.Types.Logging;
|
||||
using LBPUnion.ProjectLighthouse.Types.Mail;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
@ -63,6 +65,8 @@ public class GameServerStartup
|
|||
: new NullMailService();
|
||||
services.AddSingleton(mailService);
|
||||
|
||||
services.AddHostedService(provider => new RepeatingTaskService(provider, MaintenanceHelper.RepeatingTasks));
|
||||
|
||||
services.Configure<ForwardedHeadersOptions>
|
||||
(
|
||||
options =>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System.Globalization;
|
||||
using System.Net;
|
||||
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
||||
using LBPUnion.ProjectLighthouse.Configuration;
|
||||
using LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories;
|
||||
using LBPUnion.ProjectLighthouse.Database;
|
||||
|
@ -8,6 +9,7 @@ using LBPUnion.ProjectLighthouse.Mail;
|
|||
using LBPUnion.ProjectLighthouse.Middlewares;
|
||||
using LBPUnion.ProjectLighthouse.Servers.Website.Captcha;
|
||||
using LBPUnion.ProjectLighthouse.Servers.Website.Middlewares;
|
||||
using LBPUnion.ProjectLighthouse.Services;
|
||||
using LBPUnion.ProjectLighthouse.Types.Mail;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
|
@ -59,6 +61,8 @@ public class WebsiteStartup
|
|||
: new NullMailService();
|
||||
services.AddSingleton(mailService);
|
||||
|
||||
services.AddHostedService(provider => new RepeatingTaskService(provider, MaintenanceHelper.RepeatingTasks));
|
||||
|
||||
services.AddHttpClient<ICaptchaService, CaptchaService>("CaptchaAPI",
|
||||
client =>
|
||||
{
|
||||
|
|
120
ProjectLighthouse.Tests/Unit/RepeatingTaskTests.cs
Normal file
120
ProjectLighthouse.Tests/Unit/RepeatingTaskTests.cs
Normal file
|
@ -0,0 +1,120 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LBPUnion.ProjectLighthouse.Database;
|
||||
using LBPUnion.ProjectLighthouse.Services;
|
||||
using LBPUnion.ProjectLighthouse.Types.Maintenance;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Tests.Unit;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class RepeatingTaskTests
|
||||
{
|
||||
private class TestTask : IRepeatingTask
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public TimeSpan RepeatInterval => TimeSpan.FromSeconds(5);
|
||||
public DateTime LastRan { get; set; }
|
||||
public Task Run(DatabaseContext database) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextTask_ShouldReturnNull_WhenTaskListEmpty()
|
||||
{
|
||||
List<IRepeatingTask> tasks = new();
|
||||
IServiceProvider provider = new DefaultServiceProviderFactory().CreateServiceProvider(new ServiceCollection());
|
||||
RepeatingTaskService service = new(provider, tasks);
|
||||
|
||||
bool gotTask = service.TryGetNextTask(out IRepeatingTask? outTask);
|
||||
Assert.False(gotTask);
|
||||
Assert.Null(outTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextTask_ShouldReturnTask_WhenTaskListContainsOne()
|
||||
{
|
||||
List<IRepeatingTask> tasks = new()
|
||||
{
|
||||
new TestTask(),
|
||||
};
|
||||
IServiceProvider provider = new DefaultServiceProviderFactory().CreateServiceProvider(new ServiceCollection());
|
||||
RepeatingTaskService service = new(provider, tasks);
|
||||
|
||||
bool gotTask = service.TryGetNextTask(out IRepeatingTask? outTask);
|
||||
Assert.True(gotTask);
|
||||
Assert.NotNull(outTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextTask_ShouldReturnShortestTask_WhenTaskListContainsMultiple()
|
||||
{
|
||||
List<IRepeatingTask> tasks = new()
|
||||
{
|
||||
new TestTask
|
||||
{
|
||||
Name = "Task 1",
|
||||
LastRan = DateTime.UtcNow,
|
||||
},
|
||||
new TestTask
|
||||
{
|
||||
Name = "Task 2",
|
||||
LastRan = DateTime.UtcNow.AddMinutes(1),
|
||||
},
|
||||
new TestTask
|
||||
{
|
||||
Name = "Task 3",
|
||||
LastRan = DateTime.UtcNow.AddMinutes(-1),
|
||||
},
|
||||
};
|
||||
IServiceProvider provider = new DefaultServiceProviderFactory().CreateServiceProvider(new ServiceCollection());
|
||||
RepeatingTaskService service = new(provider, tasks);
|
||||
|
||||
bool gotTask = service.TryGetNextTask(out IRepeatingTask? outTask);
|
||||
Assert.True(gotTask);
|
||||
Assert.NotNull(outTask);
|
||||
Assert.Equal("Task 3", outTask.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_ShouldExecuteTask_AndUpdateTime()
|
||||
{
|
||||
Mock<IRepeatingTask> taskMock = new();
|
||||
taskMock.Setup(t => t.Run(It.IsAny<DatabaseContext>())).Returns(Task.CompletedTask);
|
||||
taskMock.SetupGet(t => t.RepeatInterval).Returns(TimeSpan.FromSeconds(10));
|
||||
TaskCompletionSource taskCompletion = new();
|
||||
DateTime lastRan = default;
|
||||
taskMock.SetupSet(t => t.LastRan = It.IsAny<DateTime>())
|
||||
.Callback<DateTime>(time =>
|
||||
{
|
||||
lastRan = time;
|
||||
taskCompletion.TrySetResult();
|
||||
});
|
||||
taskMock.SetupGet(t => t.LastRan).Returns(() => lastRan);
|
||||
List<IRepeatingTask> tasks = new()
|
||||
{
|
||||
taskMock.Object,
|
||||
};
|
||||
ServiceCollection serviceCollection = new();
|
||||
serviceCollection.AddScoped(_ => new Mock<DatabaseContext>().Object);
|
||||
IServiceProvider provider = new DefaultServiceProviderFactory().CreateServiceProvider(serviceCollection);
|
||||
|
||||
RepeatingTaskService service = new(provider, tasks);
|
||||
|
||||
CancellationTokenSource stoppingToken = new();
|
||||
|
||||
await service.StartAsync(stoppingToken.Token);
|
||||
|
||||
await taskCompletion.Task;
|
||||
stoppingToken.Cancel();
|
||||
|
||||
Assert.NotNull(service.ExecuteTask);
|
||||
await service.ExecuteTask;
|
||||
|
||||
taskMock.Verify(x => x.Run(It.IsAny<DatabaseContext>()), Times.Once);
|
||||
taskMock.VerifySet(x => x.LastRan = It.IsAny<DateTime>(), Times.Once());
|
||||
}
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
||||
using LBPUnion.ProjectLighthouse.Database;
|
||||
using LBPUnion.ProjectLighthouse.Logging;
|
||||
using LBPUnion.ProjectLighthouse.Types.Logging;
|
||||
using LBPUnion.ProjectLighthouse.Types.Maintenance;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Administration;
|
||||
|
||||
public static class RepeatingTaskHandler
|
||||
{
|
||||
private static bool initialized = false;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (initialized) throw new InvalidOperationException("RepeatingTaskHandler was initialized twice");
|
||||
|
||||
initialized = true;
|
||||
Task.Factory.StartNew(taskLoop);
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "FunctionNeverReturns")]
|
||||
private static async Task taskLoop()
|
||||
{
|
||||
Queue<IRepeatingTask> taskQueue = new();
|
||||
foreach (IRepeatingTask task in MaintenanceHelper.RepeatingTasks) taskQueue.Enqueue(task);
|
||||
|
||||
DatabaseContext database = DatabaseContext.CreateNewInstance();
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!taskQueue.TryDequeue(out IRepeatingTask? task))
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
continue;
|
||||
}
|
||||
|
||||
Debug.Assert(task != null);
|
||||
|
||||
if ((task.LastRan + task.RepeatInterval) <= DateTime.Now)
|
||||
{
|
||||
await task.Run(database);
|
||||
task.LastRan = DateTime.Now;
|
||||
|
||||
Logger.Debug($"Ran task \"{task.Name}\"", LogArea.Maintenance);
|
||||
}
|
||||
|
||||
taskQueue.Enqueue(task);
|
||||
Thread.Sleep(500); // Doesn't need to be that precise.
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
Logger.Warn($"Error occured while processing repeating tasks: \n{e}", LogArea.Maintenance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -64,6 +64,10 @@ public partial class DatabaseContext : DbContext
|
|||
|
||||
#endregion
|
||||
|
||||
// Used for mocking DbContext
|
||||
protected internal DatabaseContext()
|
||||
{ }
|
||||
|
||||
public DatabaseContext(DbContextOptions<DatabaseContext> options) : base(options)
|
||||
{ }
|
||||
|
||||
|
|
81
ProjectLighthouse/Services/RepeatingTaskService.cs
Normal file
81
ProjectLighthouse/Services/RepeatingTaskService.cs
Normal file
|
@ -0,0 +1,81 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LBPUnion.ProjectLighthouse.Database;
|
||||
using LBPUnion.ProjectLighthouse.Logging;
|
||||
using LBPUnion.ProjectLighthouse.Types.Logging;
|
||||
using LBPUnion.ProjectLighthouse.Types.Maintenance;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LBPUnion.ProjectLighthouse.Services;
|
||||
|
||||
public class RepeatingTaskService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider provider;
|
||||
private readonly List<IRepeatingTask> taskList;
|
||||
|
||||
public RepeatingTaskService(IServiceProvider provider, List<IRepeatingTask> tasks)
|
||||
{
|
||||
this.provider = provider;
|
||||
this.taskList = tasks;
|
||||
|
||||
Logger.Info("Initializing repeating tasks service", LogArea.Startup);
|
||||
}
|
||||
|
||||
public bool TryGetNextTask(out IRepeatingTask? outTask)
|
||||
{
|
||||
TimeSpan smallestSpan = TimeSpan.MaxValue;
|
||||
IRepeatingTask? smallestTask = null;
|
||||
foreach (IRepeatingTask task in this.taskList)
|
||||
{
|
||||
TimeSpan smallestTimeRemaining = task.RepeatInterval.Subtract(DateTime.UtcNow.Subtract(task.LastRan));
|
||||
if (smallestTimeRemaining >= smallestSpan) continue;
|
||||
|
||||
smallestSpan = smallestTimeRemaining;
|
||||
smallestTask = task;
|
||||
}
|
||||
outTask = smallestTask;
|
||||
|
||||
return outTask != null;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!this.TryGetNextTask(out IRepeatingTask? task) || task == null)
|
||||
{
|
||||
// If we fail to fetch the next task then something has gone wrong and the service should halt
|
||||
Logger.Debug("Failed to fetch next smallest task", LogArea.Maintenance);
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan timeElapsedSinceRun = DateTime.UtcNow.Subtract(task.LastRan);
|
||||
|
||||
// If the task's repeat interval hasn't elapsed
|
||||
if (timeElapsedSinceRun < task.RepeatInterval)
|
||||
{
|
||||
TimeSpan timeToWait = task.RepeatInterval.Subtract(timeElapsedSinceRun);
|
||||
Logger.Debug($"Waiting {timeToWait} for {task.Name}", LogArea.Maintenance);
|
||||
try
|
||||
{
|
||||
await Task.Delay(timeToWait, stoppingToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
using IServiceScope scope = this.provider.CreateScope();
|
||||
DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
await task.Run(database);
|
||||
Logger.Debug($"Successfully ran task \"{task.Name}\"", LogArea.Maintenance);
|
||||
task.LastRan = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ using System.Linq;
|
|||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LBPUnion.ProjectLighthouse.Administration;
|
||||
using LBPUnion.ProjectLighthouse.Administration.Maintenance;
|
||||
using LBPUnion.ProjectLighthouse.Configuration;
|
||||
using LBPUnion.ProjectLighthouse.Database;
|
||||
|
@ -93,9 +92,6 @@ public static class StartupTasks
|
|||
Logger.Info("Initializing Redis...", LogArea.Startup);
|
||||
RedisDatabase.Initialize().Wait();
|
||||
|
||||
Logger.Info("Initializing repeating tasks...", LogArea.Startup);
|
||||
RepeatingTaskHandler.Initialize();
|
||||
|
||||
// Create admin user if no users exist
|
||||
if (serverType == ServerType.Website && database.Users.CountAsync().Result == 0)
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue