Skip to content

Commit

Permalink
Merge pull request #21 from GeeWee/feature/periodic-tasks
Browse files Browse the repository at this point in the history
Periodic tasks
  • Loading branch information
GeeWee authored Sep 14, 2021
2 parents 17d6f56 + ce118dc commit 45d3d98
Show file tree
Hide file tree
Showing 9 changed files with 349 additions and 0 deletions.
62 changes: 62 additions & 0 deletions AspNetCoreTestProject/PeriodicTasks.cs
Original file line number Diff line number Diff line change
@@ -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;
}

}
8 changes: 8 additions & 0 deletions AspNetCoreTestProject/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public void ConfigureServices(IServiceCollection services)
// services.AddHostedService<YieldingAndThenCrashingBackgroundService>(); // This will not crash the application
// services.AddHostedService<ImmediatelyCrashingCriticalBackgroundService>(); // Crash
// services.AddHostedService<YieldingAndThenCrashingCriticalBackgroundService>(); // Crash

services.AddTransient<TransientStateHolder>();
services.AddSingleton<SingletonStateHolder>();
services.AddScoped<ScopeStateHolder>();
// TODO ensure that service is registered

// services.AddPeriodicTask<PrintingPeriodicTask>(PeriodicTaskFailureMode.CRASH_APPLICATION, TimeSpan.FromSeconds(1));
services.AddPeriodicTask<CountingPeriodicTask>(PeriodicTaskFailureMode.CrashApplication, TimeSpan.FromSeconds(5));
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -110,3 +111,11 @@ method on the `IServiceCollection`
services.AddHostedServiceAsSingleton<ISomeBackgroundService, SomeBackgroundService>();
```
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.
```
20 changes: 20 additions & 0 deletions src/IPeriodicTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace BetterHostedServices
{
using System.Threading;
using System.Threading.Tasks;

/// <summary>
/// A periodic tasks to be executed by a PeriodicTaskRunnerBackgroundService.
/// This task is re-created with a new scope for each invocation to ExecuteAsync.
/// </summary>
public interface IPeriodicTask
{
/// <summary>
/// This ExecuteAsync is called by the PeriodicTaskRunnerBackgroundService at a given interval.
/// Note that you will get a newly created IPeriodicTask for each invocation.
/// </summary>
/// <param name="stoppingToken">CancellationToken to listen to if you should abandon your work.</param>
/// <returns>Should return a task that will be awaited by the PeriodicTaskRunnerBackgroundService</returns>
public Task ExecuteAsync(CancellationToken stoppingToken);
}
}
28 changes: 28 additions & 0 deletions src/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
namespace BetterHostedServices
{
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

/// <summary>
/// Extensions on IServiceCollection
Expand Down Expand Up @@ -49,6 +51,32 @@ public static void AddHostedServiceAsSingleton<TService>(this IServiceCollection
services.AddHostedService<HostedServiceWrapper<TService>>();
}

/// <summary>
/// Add a periodic task
/// TODO
/// </summary>
/// <param name="services"></param>
/// <param name="failureMode"></param>
/// <param name="timeBetweenTasks"></param>
/// <typeparam name="TPeriodicTask"></typeparam>
public static void AddPeriodicTask<TPeriodicTask>(this IServiceCollection services, PeriodicTaskFailureMode failureMode, TimeSpan timeBetweenTasks)
where TPeriodicTask : class, IPeriodicTask
{
services.AddTransient<TPeriodicTask>();

services.AddHostedService<PeriodicTaskRunnerBackgroundService<TPeriodicTask>>((services) =>
{
return new PeriodicTaskRunnerBackgroundService<TPeriodicTask>(
applicationEnder: services.GetRequiredService<IApplicationEnder>(),
logger: services.GetRequiredService<ILogger<PeriodicTaskRunnerBackgroundService<TPeriodicTask>>>(),
serviceProvider: services.GetRequiredService<IServiceProvider>(),
periodicTaskFailureMode: failureMode,
timeBetweenTasks: timeBetweenTasks
);
});
}



}

Expand Down
22 changes: 22 additions & 0 deletions src/PeriodicTaskFailureMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace BetterHostedServices
{
/// <summary>
///
/// </summary>
public enum PeriodicTaskFailureMode
{
/// <summary>
/// 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.
/// </summary>
CrashApplication = 1,

/// <summary>
/// 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.
/// </summary>
RetryLater = 5
}
}
83 changes: 83 additions & 0 deletions src/PeriodicTaskRunnerBackgroundService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace BetterHostedServices
{
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

/// <summary>
///
/// </summary>
/// <typeparam name="TPeriodicTask"></typeparam>
public class PeriodicTaskRunnerBackgroundService<TPeriodicTask> : CriticalBackgroundService
where TPeriodicTask : IPeriodicTask
{
private readonly ILogger<PeriodicTaskRunnerBackgroundService<TPeriodicTask>> logger;
private readonly IServiceProvider serviceProvider;

private readonly PeriodicTaskFailureMode periodicTaskFailureMode;
private readonly TimeSpan timeBetweenTasks;

/// <summary>
///
/// </summary>
/// <param name="applicationEnder"></param>
/// <param name="logger"></param>
/// <param name="serviceProvider"></param>
/// <param name="periodicTaskFailureMode"></param>
/// <param name="timeBetweenTasks"></param>
public PeriodicTaskRunnerBackgroundService(
IApplicationEnder applicationEnder,
ILogger<PeriodicTaskRunnerBackgroundService<TPeriodicTask>> logger,
IServiceProvider serviceProvider,
PeriodicTaskFailureMode periodicTaskFailureMode,
TimeSpan timeBetweenTasks) : base(applicationEnder)
{
this.logger = logger;
this.serviceProvider = serviceProvider;
this.periodicTaskFailureMode = periodicTaskFailureMode;
this.timeBetweenTasks = timeBetweenTasks;
}

/// <summary>
/// Executes the given TPeriodicTask in a new scope each time.
/// </summary>
/// <param name="stoppingToken"></param>
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<TPeriodicTask>();
}

while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = this.serviceProvider.CreateScope();
var periodicTask = scope.ServiceProvider.GetRequiredService<TPeriodicTask>();
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);
}
}
}
}
33 changes: 33 additions & 0 deletions tests/IntegrationUtils/PeriodicTaskTestClasses.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
84 changes: 84 additions & 0 deletions tests/PeriodicTasksTest.cs
Original file line number Diff line number Diff line change
@@ -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<DummyStartup> _factory;

public PeriodicTasksTest()
{
_factory = new CustomWebApplicationFactory<DummyStartup>();
}

[Fact]
public async Task PeriodicTask_ShouldEndApplication_IfFailureModeIsSetToCrash()
{
// Arrange
var applicationEnder = new ApplicationEnderMock();

var factory = this._factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddTransient<IApplicationEnder>(s => applicationEnder);
services.AddPeriodicTask<CrashingPeriodicTask>(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<IApplicationEnder>(s => applicationEnder);
services.AddPeriodicTask<IncrementingThenCrashingPeriodicTask>(PeriodicTaskFailureMode.RetryLater, TimeSpan.FromMilliseconds(50));
services.AddSingleton<SingletonStateHolder>(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();
}
}
}

0 comments on commit 45d3d98

Please sign in to comment.