From 74d6d8086d757caf631d328bc86aaa8e558f1723 Mon Sep 17 00:00:00 2001 From: geewee Date: Mon, 13 Sep 2021 14:52:04 +0200 Subject: [PATCH 1/3] emphasize it's not necessarily aspnet core --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bc6f43b..118942d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![GitHub Actions Build History](https://buildstats.info/github/chart/geewee/BetterHostedServices?branch=main&includeBuildsFromPullRequest=false)](https://github.com/geewee/BetterHostedServices/actions) This projects is out to solve some limitations with ASP.NET Core's `IHostedService` and `BackgroundService`. +The project also works with console applications using a [.NET Generic Host](https://docs.microsoft.com/en-us/dotnet/core/extensions/generic-host). ### Problem 1. IHostedService is not good for long running tasks. Creating an `IHostedService` with a long-running task, will delay application startup. From 248dede7310c51cc44d89e880f2091e77fcaf886 Mon Sep 17 00:00:00 2001 From: geewee Date: Mon, 13 Sep 2021 15:30:08 +0200 Subject: [PATCH 2/3] periodic task implementation seems to work --- AspNetCoreTestProject/PeriodicTasks.cs | 62 ++++++++++++++++ AspNetCoreTestProject/Startup.cs | 8 +++ README.md | 8 +++ src/IPeriodicTask.cs | 20 ++++++ src/IServiceCollectionExtensions.cs | 28 ++++++++ src/PeriodicTaskFailureMode.cs | 22 ++++++ src/PeriodicTaskRunnerBackgroundService.cs | 83 ++++++++++++++++++++++ 7 files changed, 231 insertions(+) create mode 100644 AspNetCoreTestProject/PeriodicTasks.cs create mode 100644 src/IPeriodicTask.cs create mode 100644 src/PeriodicTaskFailureMode.cs create mode 100644 src/PeriodicTaskRunnerBackgroundService.cs diff --git a/AspNetCoreTestProject/PeriodicTasks.cs b/AspNetCoreTestProject/PeriodicTasks.cs new file mode 100644 index 0000000..3b618e2 --- /dev/null +++ b/AspNetCoreTestProject/PeriodicTasks.cs @@ -0,0 +1,62 @@ +namespace AspNetCoreTestProject +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using BetterHostedServices; + + public class PrintingPeriodicTask : IPeriodicTask + { + public Task ExecuteAsync(CancellationToken stoppingToken) + { + Console.WriteLine("Hello world"); + return Task.CompletedTask; + } + } + + public class CountingPeriodicTask : IPeriodicTask + { + private SingletonStateHolder singletonStateHolder; + private TransientStateHolder transientStateHolder; + private ScopeStateHolder scopeStateHolder; + + public CountingPeriodicTask(TransientStateHolder transientStateHolder, SingletonStateHolder singletonStateHolder, ScopeStateHolder scopeStateHolder) + { + this.transientStateHolder = transientStateHolder; + this.singletonStateHolder = singletonStateHolder; + this.scopeStateHolder = scopeStateHolder; + } + + public Task ExecuteAsync(CancellationToken stoppingToken) + { + this.transientStateHolder.Count += 1; + this.singletonStateHolder.Count += 1; + this.scopeStateHolder.Count += 1; + + var random = new Random(); + if (random.Next(0, 10) < 2) + { + throw new Exception("Oh no something went horribly wrong."); + } + + Console.WriteLine($"Transient state: {this.transientStateHolder.Count}. Scope state: {this.scopeStateHolder.Count}. Singleton state: {this.singletonStateHolder.Count}"); + return Task.CompletedTask; + } + } + + public class SingletonStateHolder + { + public int Count { get; set; } = 0; + } + + public class ScopeStateHolder + { + public int Count { get; set; } = 0; + } + + public class TransientStateHolder + { + public int Count { get; set; } = 0; + } + +} diff --git a/AspNetCoreTestProject/Startup.cs b/AspNetCoreTestProject/Startup.cs index 1a16d3c..3bca3c7 100644 --- a/AspNetCoreTestProject/Startup.cs +++ b/AspNetCoreTestProject/Startup.cs @@ -38,6 +38,14 @@ public void ConfigureServices(IServiceCollection services) // services.AddHostedService(); // This will not crash the application // services.AddHostedService(); // Crash // services.AddHostedService(); // Crash + + services.AddTransient(); + services.AddSingleton(); + services.AddScoped(); + // TODO ensure that service is registered + + // services.AddPeriodicTask(PeriodicTaskFailureMode.CRASH_APPLICATION, TimeSpan.FromSeconds(1)); + services.AddPeriodicTask(PeriodicTaskFailureMode.CrashApplication, TimeSpan.FromSeconds(5)); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/README.md b/README.md index 118942d..f1009bf 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,11 @@ method on the `IServiceCollection` services.AddHostedServiceAsSingleton(); ``` After that, you can inject them via the DI container just like any ordinary singleton. + +## RunPeriodicTasks +If you simply want your BackgroundService to run a periodic tasks, there's some boilerplate you generally have to deal with. +Best-practices for using BackgroundServices to run periodic tasks are [documented here](https://www.gustavwengel.dk/testing-and-scope-management-aspnetcore-backgroundservices) - but you can also use this library. + +```csharp +services. +``` diff --git a/src/IPeriodicTask.cs b/src/IPeriodicTask.cs new file mode 100644 index 0000000..9b24acf --- /dev/null +++ b/src/IPeriodicTask.cs @@ -0,0 +1,20 @@ +namespace BetterHostedServices +{ + using System.Threading; + using System.Threading.Tasks; + + /// + /// A periodic tasks to be executed by a PeriodicTaskRunnerBackgroundService. + /// This task is re-created with a new scope for each invocation to ExecuteAsync. + /// + public interface IPeriodicTask + { + /// + /// This ExecuteAsync is called by the PeriodicTaskRunnerBackgroundService at a given interval. + /// Note that you will get a newly created IPeriodicTask for each invocation. + /// + /// CancellationToken to listen to if you should abandon your work. + /// Should return a task that will be awaited by the PeriodicTaskRunnerBackgroundService + public Task ExecuteAsync(CancellationToken stoppingToken); + } +} diff --git a/src/IServiceCollectionExtensions.cs b/src/IServiceCollectionExtensions.cs index 2426cc8..95fba0f 100644 --- a/src/IServiceCollectionExtensions.cs +++ b/src/IServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ namespace BetterHostedServices { + using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; /// /// Extensions on IServiceCollection @@ -49,6 +51,32 @@ public static void AddHostedServiceAsSingleton(this IServiceCollection services.AddHostedService>(); } + /// + /// Add a periodic task + /// TODO + /// + /// + /// + /// + /// + public static void AddPeriodicTask(this IServiceCollection services, PeriodicTaskFailureMode failureMode, TimeSpan timeBetweenTasks) + where TPeriodicTask : class, IPeriodicTask + { + services.AddTransient(); + + services.AddHostedService>((services) => + { + return new PeriodicTaskRunnerBackgroundService( + applicationEnder: services.GetRequiredService(), + logger: services.GetRequiredService>>(), + serviceProvider: services.GetRequiredService(), + periodicTaskFailureMode: failureMode, + timeBetweenTasks: timeBetweenTasks + ); + }); + } + + } diff --git a/src/PeriodicTaskFailureMode.cs b/src/PeriodicTaskFailureMode.cs new file mode 100644 index 0000000..bd6c133 --- /dev/null +++ b/src/PeriodicTaskFailureMode.cs @@ -0,0 +1,22 @@ +namespace BetterHostedServices +{ + /// + /// + /// + public enum PeriodicTaskFailureMode + { + /// + /// If this failure mode is set and a task throws an uncaught application + /// the task will crash the application. + /// It has the same behaviour as letting the task bubble up from a CriticalBackgroundService. + /// + CrashApplication = 1, + + /// + /// If this failure mode is set and a task throws an uncaught application, the error will be logged via + /// the standard ILogger implementation, and nothing further will be done. + /// The next periodic task will continue as planned. + /// + RetryLater = 5 + } +} diff --git a/src/PeriodicTaskRunnerBackgroundService.cs b/src/PeriodicTaskRunnerBackgroundService.cs new file mode 100644 index 0000000..e8a78be --- /dev/null +++ b/src/PeriodicTaskRunnerBackgroundService.cs @@ -0,0 +1,83 @@ +namespace BetterHostedServices +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + + /// + /// + /// + /// + public class PeriodicTaskRunnerBackgroundService : CriticalBackgroundService + where TPeriodicTask : IPeriodicTask + { + private readonly ILogger> logger; + private readonly IServiceProvider serviceProvider; + + private readonly PeriodicTaskFailureMode periodicTaskFailureMode; + private readonly TimeSpan timeBetweenTasks; + + /// + /// + /// + /// + /// + /// + /// + /// + public PeriodicTaskRunnerBackgroundService( + IApplicationEnder applicationEnder, + ILogger> logger, + IServiceProvider serviceProvider, + PeriodicTaskFailureMode periodicTaskFailureMode, + TimeSpan timeBetweenTasks) : base(applicationEnder) + { + this.logger = logger; + this.serviceProvider = serviceProvider; + this.periodicTaskFailureMode = periodicTaskFailureMode; + this.timeBetweenTasks = timeBetweenTasks; + } + + /// + /// Executes the given TPeriodicTask in a new scope each time. + /// + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Ensure that we can crate the service. Do this synchronously so that we'll fail-fast no matter the failure mode + // if the task can't run at all. + using (var scope = this.serviceProvider.CreateScope()) + { + _ = scope.ServiceProvider.GetRequiredService(); + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = this.serviceProvider.CreateScope(); + var periodicTask = scope.ServiceProvider.GetRequiredService(); + await periodicTask.ExecuteAsync(stoppingToken); + } + catch (Exception e) + { + this.logger.LogError(e, $"Exception while processing message in {typeof(TPeriodicTask)}"); + // If failure mode is set to end application, go through the normal OnError flow that crashes the application. + if (this.periodicTaskFailureMode == PeriodicTaskFailureMode.CrashApplication) + { + this.OnError(e); + } + + if (this.periodicTaskFailureMode == PeriodicTaskFailureMode.RetryLater) + { + this.logger.LogWarning(e, $"Exception while processing message in {typeof(TPeriodicTask)}. Retrying in {this.timeBetweenTasks}"); + } + } + + await Task.Delay(this.timeBetweenTasks, stoppingToken); + } + } + } +} From ce118dc94c4de109fdcec9afc737b02a26b04cb8 Mon Sep 17 00:00:00 2001 From: geewee Date: Mon, 13 Sep 2021 15:42:57 +0200 Subject: [PATCH 3/3] tests work --- .../PeriodicTaskTestClasses.cs | 33 ++++++++ tests/PeriodicTasksTest.cs | 84 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/IntegrationUtils/PeriodicTaskTestClasses.cs create mode 100644 tests/PeriodicTasksTest.cs diff --git a/tests/IntegrationUtils/PeriodicTaskTestClasses.cs b/tests/IntegrationUtils/PeriodicTaskTestClasses.cs new file mode 100644 index 0000000..3facca9 --- /dev/null +++ b/tests/IntegrationUtils/PeriodicTaskTestClasses.cs @@ -0,0 +1,33 @@ +namespace BetterHostedServices.Test.IntegrationUtils +{ + using System; + using System.Threading; + using System.Threading.Tasks; + + public class CrashingPeriodicTask: IPeriodicTask + { + public async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(50, stoppingToken); // Just to yield control + throw new Exception("oh no"); + } + } + + public class IncrementingThenCrashingPeriodicTask: IPeriodicTask + { + private readonly SingletonStateHolder singletonStateHolder; + + public IncrementingThenCrashingPeriodicTask(SingletonStateHolder singletonStateHolder) => this.singletonStateHolder = singletonStateHolder; + + public async Task ExecuteAsync(CancellationToken stoppingToken) + { + this.singletonStateHolder.Count += 1; + throw new Exception("oh no"); + } + } + + public class SingletonStateHolder + { + public int Count { get; set; } + } +} diff --git a/tests/PeriodicTasksTest.cs b/tests/PeriodicTasksTest.cs new file mode 100644 index 0000000..632fe24 --- /dev/null +++ b/tests/PeriodicTasksTest.cs @@ -0,0 +1,84 @@ +namespace BetterHostedServices.Test +{ + using System; + using System.Threading.Tasks; + using FluentAssertions; + using IntegrationUtils; + using Microsoft.AspNetCore.TestHost; + using Microsoft.Extensions.DependencyInjection; + using Xunit; + + public class PeriodicTasksTest + { + private readonly CustomWebApplicationFactory _factory; + + public PeriodicTasksTest() + { + _factory = new CustomWebApplicationFactory(); + } + + [Fact] + public async Task PeriodicTask_ShouldEndApplication_IfFailureModeIsSetToCrash() + { + // Arrange + var applicationEnder = new ApplicationEnderMock(); + + var factory = this._factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddTransient(s => applicationEnder); + services.AddPeriodicTask(PeriodicTaskFailureMode.CrashApplication, TimeSpan.FromSeconds(1)); + }); + }); + + var client = factory.CreateClient(); + // Act & assert - we should crash here at some point + + // Task is hella flaky because it depends on the internals of the IHostedService - try yielding a bunch of times + // to hope that it's done requesting application shutdown at this point + for (int i = 0; i < 10; i++) + { + await Task.Delay(50); + await Task.Yield(); + } + + // due to https://github.com/dotnet/aspnetcore/issues/25857 we can't test if the process is closed directly + applicationEnder.ShutDownRequested.Should().BeTrue(); + } + + [Fact] + public async Task PeriodicTask_ShouldContinueRunningTasks_IfFailureModeIsSetToRetry() + { + // Arrange + var applicationEnder = new ApplicationEnderMock(); + var stateHolder = new SingletonStateHolder(); + + var factory = this._factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.AddTransient(s => applicationEnder); + services.AddPeriodicTask(PeriodicTaskFailureMode.RetryLater, TimeSpan.FromMilliseconds(50)); + services.AddSingleton(s => stateHolder); + }); + }); + + var client = factory.CreateClient(); + // Act & assert - we crash here after each invocation, but we truck on. The stateHolder should keep being incremented + + // Task is hella flaky because it depends on the internals of the IHostedService - try yielding a bunch of times + // to hope that it's done requesting application shutdown at this point + for (int i = 0; i < 10; i++) + { + await Task.Delay(100); // 1s ms all in all + await Task.Yield(); + } + + stateHolder.Count.Should().BeGreaterThan(5); + + // due to https://github.com/dotnet/aspnetcore/issues/25857 we can't test if the process is closed directly + applicationEnder.ShutDownRequested.Should().BeFalse(); + } + } +}