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:
Josh 2023-06-17 14:48:24 -05:00 committed by GitHub
parent 9ac8a166d4
commit a0d021f1e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 213 additions and 69 deletions

View file

@ -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 =>

View file

@ -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 =>
{

View 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());
}
}

View file

@ -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);
}
}
}
}

View file

@ -64,6 +64,10 @@ public partial class DatabaseContext : DbContext
#endregion
// Used for mocking DbContext
protected internal DatabaseContext()
{ }
public DatabaseContext(DbContextOptions<DatabaseContext> options) : base(options)
{ }

View 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;
}
}
}

View file

@ -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)
{