Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ public static IServiceCollection AddBackgroundService(
// If no configuration delegate is provided, use defaults
if (configureOptions != null)
{
services.Configure(configureOptions);
services.AddOptions<BackgroundServiceOptions>()
.Configure(configureOptions)
.ValidateDataAnnotations()
.ValidateOnStart();
}
else
{
services.Configure<BackgroundServiceOptions>(_ => { });
services.AddOptions<BackgroundServiceOptions>()
.Configure(_ => { })
.ValidateOnStart();
}

services.AddSingleton<IBackgroundServiceFactory, BackgroundServiceFactory>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,29 @@
using MediatR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using DfE.CoreLibs.AsyncProcessing.Configurations;

namespace DfE.CoreLibs.AsyncProcessing.Services
{
public class BackgroundServiceFactory(IMediator mediator, IOptions<BackgroundServiceOptions> options) : BackgroundService, IBackgroundServiceFactory
public class BackgroundServiceFactory(
IMediator mediator,
IOptions<BackgroundServiceOptions> options,
ILogger<BackgroundServiceFactory> logger) : BackgroundService, IBackgroundServiceFactory
{
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));

private readonly ILogger<BackgroundServiceFactory> _logger = logger ?? throw new ArgumentNullException(nameof(logger));

private readonly ConcurrentDictionary<Type, ConcurrentQueue<Func<CancellationToken, Task>>> _taskQueues = new();

private readonly ConcurrentDictionary<Type, SemaphoreSlim> _semaphores = new();

private CancellationToken _serviceStoppingToken = CancellationToken.None;

private readonly TaskCompletionSource _tokenInitialisedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

/// <inheritdoc />
public Task<TResult> EnqueueTask<TResult, TEvent>(
Func<CancellationToken, Task<TResult>> taskFunc,
Expand Down Expand Up @@ -79,24 +86,30 @@ public Task<TResult> EnqueueTask<TResult, TEvent>(
}
});

_ = StartProcessingQueue(taskType);
var processingTask = StartProcessingQueue(taskType);
_ = processingTask.ContinueWith(t =>
{
if (t.Exception != null)
{
_logger.LogError(t.Exception, "Error occurred while processing background queue for type {TaskType}", taskType);
}
}, TaskContinuationOptions.OnlyOnFaulted);
return tcs.Task;
}

private async Task StartProcessingQueue(Type taskType)
{
Console.WriteLine("StartProcessingQueue triggered");
_logger.LogDebug("StartProcessingQueue triggered for {TaskType}", taskType);

var queue = _taskQueues[taskType];
var semaphore = _semaphores[taskType];

while (options.Value.UseGlobalStoppingToken && _serviceStoppingToken == CancellationToken.None)
if (options.Value.UseGlobalStoppingToken)
{
Console.WriteLine("Waiting for ExecuteAsync to initialize _serviceStoppingToken...");
await Task.Delay(100);
await _tokenInitialisedTcs.Task.ConfigureAwait(false);
}

Console.WriteLine($"_serviceStoppingToken IsCancellationRequested: {_serviceStoppingToken.IsCancellationRequested}");
_logger.LogDebug("_serviceStoppingToken IsCancellationRequested: {IsCancellationRequested}", _serviceStoppingToken.IsCancellationRequested);

await semaphore.WaitAsync(CancellationToken.None).ConfigureAwait(false);

Expand All @@ -106,7 +119,7 @@ private async Task StartProcessingQueue(Type taskType)
{
var token = options.Value.UseGlobalStoppingToken ? _serviceStoppingToken : CancellationToken.None;

Console.WriteLine($"Processing task with token IsCancellationRequested: {token.IsCancellationRequested}");
_logger.LogDebug("Processing task with token IsCancellationRequested: {IsCancellationRequested}", token.IsCancellationRequested);
await taskToProcess(token).ConfigureAwait(false);
}
}
Expand All @@ -119,32 +132,33 @@ private async Task StartProcessingQueue(Type taskType)

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Debug.WriteLine("ExecuteAsync started");
_logger.LogDebug("ExecuteAsync started");

if (options.Value.UseGlobalStoppingToken)
{
_serviceStoppingToken = stoppingToken;
Debug.WriteLine("_serviceStoppingToken assigned");
_tokenInitialisedTcs.TrySetResult();
_logger.LogDebug("_serviceStoppingToken assigned");
}

while (!stoppingToken.IsCancellationRequested)
{
Debug.WriteLine("ExecuteAsync loop running...");
_logger.LogDebug("ExecuteAsync loop running...");
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken).ConfigureAwait(false);
}

Debug.WriteLine("ExecuteAsync detected cancellation.");
_logger.LogDebug("ExecuteAsync detected cancellation.");
}

public override async Task StartAsync(CancellationToken cancellationToken)
{
Debug.WriteLine("Background service is starting...");
_logger.LogDebug("Background service is starting...");

var executeTask = base.StartAsync(cancellationToken);

await Task.Delay(500);

Debug.WriteLine("Background service started, ExecuteAsync is now running.");
_logger.LogDebug("Background service started, ExecuteAsync is now running.");

await executeTask;
}
Expand Down
1 change: 1 addition & 0 deletions src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
5 changes: 4 additions & 1 deletion src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddServiceCaching(
this IServiceCollection services, IConfiguration config)
{
services.Configure<CacheSettings>(config.GetSection("CacheSettings"));
services.AddOptions<CacheSettings>()
.Bind(config.GetSection("CacheSettings"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<ICacheService<IMemoryCacheType>, MemoryCacheService>();

return services;
Expand Down
1 change: 1 addition & 0 deletions src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Identity.Web" Version="3.8.2" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
18 changes: 15 additions & 3 deletions src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ public static class ServiceCollectionExtensions
/// <returns>The updated <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddUserTokenService(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<TokenSettings>(configuration.GetSection("Authorization:TokenSettings"));
services.AddOptions<TokenSettings>()
.Bind(configuration.GetSection("Authorization:TokenSettings"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddScoped<IUserTokenService, UserTokenService>();
services.AddHttpContextAccessor();
return services;
Expand All @@ -34,7 +37,10 @@ public static IServiceCollection AddUserTokenService(this IServiceCollection ser
/// <returns>The updated <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddApiOboTokenService(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<TokenSettings>(configuration.GetSection("Authorization:TokenSettings"));
services.AddOptions<TokenSettings>()
.Bind(configuration.GetSection("Authorization:TokenSettings"))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddScoped<IApiOboTokenService, ApiOboTokenService>();
services.AddHttpContextAccessor();
return services;
Expand All @@ -56,7 +62,13 @@ public static IServiceCollection AddApiOboTokenService(this IServiceCollection s
/// <exception cref="ArgumentNullException">Thrown when the TokenSettings section is missing in configuration.</exception>
public static IServiceCollection AddCustomJwtAuthentication(this IServiceCollection services, IConfiguration configuration, string authenticationScheme, AuthenticationBuilder authenticationBuilder, JwtBearerEvents? jwtBearerEvents = null)
{
var tokenSettings = configuration.GetSection("Authorization:TokenSettings").Get<TokenSettings>();
var tokenSettingsSection = configuration.GetSection("Authorization:TokenSettings");
services.AddOptions<TokenSettings>()
.Bind(tokenSettingsSection)
.ValidateDataAnnotations()
.ValidateOnStart();

var tokenSettings = tokenSettingsSection.Get<TokenSettings>();

if (tokenSettings == null)
{
Expand Down
6 changes: 3 additions & 3 deletions src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static string SplitPascalCase<T>(this T source)
return source == null
? string.Empty
: Regex.Replace(source.ToString() ?? string.Empty, "[A-Z]", " $0", RegexOptions.None,
TimeSpan.FromSeconds(1)).Trim();
TimeSpan.FromSeconds(5)).Trim();
}

/// <summary>
Expand Down Expand Up @@ -154,7 +154,7 @@ public static string ToFirstUpper(this string input)
/// <returns>A string.</returns>
public static string ToHyphenated(this string str)
{
var whitespaceRegex = new Regex(@"\s+", RegexOptions.None, TimeSpan.FromSeconds(1));
var whitespaceRegex = new Regex(@"\s+", RegexOptions.None, TimeSpan.FromSeconds(5));
return whitespaceRegex.Replace(str, "-");
}

Expand All @@ -165,7 +165,7 @@ public static string ToHyphenated(this string str)
/// <returns>A string.</returns>
public static string RemoveNonAlphanumericOrWhiteSpace(this string str)
{
var notAlphanumericWhiteSpaceOrHyphen = new Regex(@"[^\w\s-]", RegexOptions.None, TimeSpan.FromSeconds(1));
var notAlphanumericWhiteSpaceOrHyphen = new Regex(@"[^\w\s-]", RegexOptions.None, TimeSpan.FromSeconds(5));
return notAlphanumericWhiteSpaceOrHyphen.Replace(str, string.Empty);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
using MediatR;
using NSubstitute;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;

namespace DfE.CoreLibs.AsyncProcessing.Tests.Services
{
public class BackgroundServiceFactoryTests : IDisposable
{
private readonly IMediator _mediator;
private readonly ILogger<BackgroundServiceFactory> _logger;
private readonly BackgroundServiceFactory _factoryNoGlobalToken;
private readonly BackgroundServiceFactory _factoryWithGlobalToken;

Expand All @@ -20,6 +22,7 @@ public class BackgroundServiceFactoryTests : IDisposable
public BackgroundServiceFactoryTests()
{
_mediator = Substitute.For<IMediator>();
_logger = Substitute.For<ILogger<BackgroundServiceFactory>>();

_cts = new CancellationTokenSource();

Expand All @@ -33,8 +36,8 @@ public BackgroundServiceFactoryTests()
UseGlobalStoppingToken = true
});

_factoryNoGlobalToken = new BackgroundServiceFactory(_mediator, optionsNoGlobalToken);
_factoryWithGlobalToken = new BackgroundServiceFactory(_mediator, optionsGlobalToken);
_factoryNoGlobalToken = new BackgroundServiceFactory(_mediator, optionsNoGlobalToken, _logger);
_factoryWithGlobalToken = new BackgroundServiceFactory(_mediator, optionsGlobalToken, _logger);
}

public void Dispose()
Expand Down Expand Up @@ -391,5 +394,54 @@ public async Task Task_ShouldCompleteSuccessfully_IfNoTokenIsCanceled()
Assert.True(result);
}

[Fact]
public async Task Task_ShouldNotProcessUntilServiceStarted_WhenUseGlobalStoppingToken()
{
var processed = false;

_factoryWithGlobalToken.EnqueueTask<bool, IBackgroundServiceEvent>(token =>
{
processed = true;
return Task.FromResult(true);
}, null);

await Task.Delay(200);

Assert.False(processed, "Task should not execute before StartAsync assigns the global token.");

var runTask = _factoryWithGlobalToken.StartAsync(_cts.Token);

await Task.Delay(200);

_cts.Cancel();
await runTask;

Assert.True(processed, "Task should execute after the service starts and global token is assigned.");
}

[Fact]
public async Task EnqueueTask_ShouldLogError_WhenTaskThrows()
{
// Arrange
_factoryNoGlobalToken.EnqueueTask<int, IBackgroundServiceEvent>(token => throw new InvalidOperationException("boom"));

var cts = new CancellationTokenSource();
var runTask = _factoryNoGlobalToken.StartAsync(cts.Token);

await Task.Delay(200);
cts.Cancel();
await runTask;

// Assert
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(v => v.ToString()!.Contains("Error occurred while processing background queue")),
Arg.Is<AggregateException>(ex =>
ex.InnerException.Message == "boom"),
Arg.Any<Func<object, Exception?, string>>());
}


}
}