diff --git a/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj b/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj
index 7d101d8d..bebf6d17 100644
--- a/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj
+++ b/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs
index bec34b36..168914ff 100644
--- a/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs
+++ b/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs
@@ -19,11 +19,16 @@ public static IServiceCollection AddBackgroundService(
// If no configuration delegate is provided, use defaults
if (configureOptions != null)
{
- services.Configure(configureOptions);
+ services.AddOptions()
+ .Configure(configureOptions)
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
}
else
{
- services.Configure(_ => { });
+ services.AddOptions()
+ .Configure(_ => { })
+ .ValidateOnStart();
}
services.AddSingleton();
diff --git a/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs
index 3613a162..0f3e2e0b 100644
--- a/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs
+++ b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs
@@ -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 options) : BackgroundService, IBackgroundServiceFactory
+ public class BackgroundServiceFactory(
+ IMediator mediator,
+ IOptions options,
+ ILogger logger) : BackgroundService, IBackgroundServiceFactory
{
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
private readonly ConcurrentDictionary>> _taskQueues = new();
private readonly ConcurrentDictionary _semaphores = new();
private CancellationToken _serviceStoppingToken = CancellationToken.None;
+ private readonly TaskCompletionSource _tokenInitialisedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
///
public Task EnqueueTask(
Func> taskFunc,
@@ -79,24 +86,30 @@ public Task EnqueueTask(
}
});
- _ = 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);
@@ -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);
}
}
@@ -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;
}
diff --git a/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj
index 1b2ae5f3..56352fc7 100644
--- a/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj
+++ b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs
index 7bf7eb48..2f39a49e 100644
--- a/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs
+++ b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs
@@ -10,7 +10,10 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddServiceCaching(
this IServiceCollection services, IConfiguration config)
{
- services.Configure(config.GetSection("CacheSettings"));
+ services.AddOptions()
+ .Bind(config.GetSection("CacheSettings"))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
services.AddSingleton, MemoryCacheService>();
return services;
diff --git a/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj
index 24404a23..ca604580 100644
--- a/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj
+++ b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs
index ca27db80..42c75ff9 100644
--- a/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs
+++ b/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs
@@ -20,7 +20,10 @@ public static class ServiceCollectionExtensions
/// The updated .
public static IServiceCollection AddUserTokenService(this IServiceCollection services, IConfiguration configuration)
{
- services.Configure(configuration.GetSection("Authorization:TokenSettings"));
+ services.AddOptions()
+ .Bind(configuration.GetSection("Authorization:TokenSettings"))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
services.AddScoped();
services.AddHttpContextAccessor();
return services;
@@ -34,7 +37,10 @@ public static IServiceCollection AddUserTokenService(this IServiceCollection ser
/// The updated .
public static IServiceCollection AddApiOboTokenService(this IServiceCollection services, IConfiguration configuration)
{
- services.Configure(configuration.GetSection("Authorization:TokenSettings"));
+ services.AddOptions()
+ .Bind(configuration.GetSection("Authorization:TokenSettings"))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
services.AddScoped();
services.AddHttpContextAccessor();
return services;
@@ -56,7 +62,13 @@ public static IServiceCollection AddApiOboTokenService(this IServiceCollection s
/// Thrown when the TokenSettings section is missing in configuration.
public static IServiceCollection AddCustomJwtAuthentication(this IServiceCollection services, IConfiguration configuration, string authenticationScheme, AuthenticationBuilder authenticationBuilder, JwtBearerEvents? jwtBearerEvents = null)
{
- var tokenSettings = configuration.GetSection("Authorization:TokenSettings").Get();
+ var tokenSettingsSection = configuration.GetSection("Authorization:TokenSettings");
+ services.AddOptions()
+ .Bind(tokenSettingsSection)
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ var tokenSettings = tokenSettingsSection.Get();
if (tokenSettings == null)
{
diff --git a/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs
index 14e052c4..cfb0559b 100644
--- a/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs
+++ b/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs
@@ -19,7 +19,7 @@ public static string SplitPascalCase(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();
}
///
@@ -154,7 +154,7 @@ public static string ToFirstUpper(this string input)
/// A string.
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, "-");
}
@@ -165,7 +165,7 @@ public static string ToHyphenated(this string str)
/// A string.
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);
}
}
\ No newline at end of file
diff --git a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs
index 6c994e1a..950ddfac 100644
--- a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs
+++ b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs
@@ -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 _logger;
private readonly BackgroundServiceFactory _factoryNoGlobalToken;
private readonly BackgroundServiceFactory _factoryWithGlobalToken;
@@ -20,6 +22,7 @@ public class BackgroundServiceFactoryTests : IDisposable
public BackgroundServiceFactoryTests()
{
_mediator = Substitute.For();
+ _logger = Substitute.For>();
_cts = new CancellationTokenSource();
@@ -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()
@@ -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(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(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(),
+ Arg.Is