refactor: Extract ProfileWorker into modular components

- Create ProfileWorker namespace with 4 separate components
- ProfileWorker.cs: BackgroundService orchestrator with IOptions support
- ProfileWorkerOptions.cs: Configuration model with validation
- ProfileCache.cs: Thread-safe cache using composition pattern
- DependencyInjection.cs: Service registration with IValidateOptions

Benefits:
- Separation of concerns (orchestration, config, state, DI)
- IOptions pattern for appsettings.json configuration
- Startup validation for configuration errors
- Better testability and maintainability
This commit is contained in:
2026-07-13 11:59:23 +02:00
parent cba1aa85d0
commit f67c321380
4 changed files with 119 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.Options;
namespace ECMJobRunner.WebCron.ProfileWorker;
public static class DependencyInjection
{
public static IServiceCollection AddProfileWorker(this IServiceCollection services, IConfiguration configuration)
{
// Configure ProfileWorker options from appsettings.json
services.Configure<ProfileWorkerOptions>(
configuration.GetSection(ProfileWorkerOptions.SectionName));
// Validate options at startup
services.AddSingleton<IValidateOptions<ProfileWorkerOptions>, ProfileWorkerOptionsValidator>();
services.AddHostedService<ProfileWorker>();
services.AddSingleton<ProfileCache>();
services.AddScoped<ProfileWork>();
return services;
}
}
/// <summary>
/// Validates ProfileWorkerOptions configuration at startup.
/// </summary>
internal class ProfileWorkerOptionsValidator : IValidateOptions<ProfileWorkerOptions>
{
public ValidateOptionsResult Validate(string? name, ProfileWorkerOptions options)
{
if (options.IntervalMS <= 0)
{
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs must be greater than 0");
}
if (options.IntervalMS < 100)
{
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs should be at least 100ms to avoid excessive CPU usage");
}
return ValidateOptionsResult.Success;
}
}

View File

@@ -0,0 +1,23 @@
using ECMJobRunner.Application.Common.Dtos;
using System.Collections.Concurrent;
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Thread-safe cache for storing active profile configurations.
/// Used to track profile state and detect changes in schedule or removal.
/// </summary>
public class ProfileCache
{
private readonly ConcurrentDictionary<string, CfgProfileDto> _cache = new();
public CfgProfileDto? Get(string jobId) => _cache.TryGetValue(jobId, out var profile) ? profile : null;
public void AddOrUpdate(string jobId, CfgProfileDto profile) => _cache[jobId] = profile;
public bool TryRemove(string jobId, out CfgProfileDto? profile) => _cache.TryRemove(jobId, out profile);
public IEnumerable<string> GetAllJobIds() => _cache.Keys;
public bool TryGetValue(string jobId, out CfgProfileDto? profile) => _cache.TryGetValue(jobId, out profile);
}

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.Options;
namespace ECMJobRunner.WebCron.ProfileWorker;
public class ProfileWorker(
ILogger<ProfileWorker> Logger,
IServiceScopeFactory ScopeFactory,
IOptions<ProfileWorkerOptions> Options) : BackgroundService
{
private readonly ProfileWorkerOptions _options = Options.Value;
private int _workCount = 0;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_workCount++;
if (Logger.IsEnabled(LogLevel.Information))
{
Logger.LogInformation("Worker running {workCount} at: {time}", _workCount, DateTimeOffset.Now);
}
try
{
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
using var scope = ScopeFactory.CreateScope();
var work = scope.ServiceProvider.GetRequiredService<ProfileWork>();
await work.ExecuteAsync(stoppingToken);
}
catch (Exception ex)
{
Logger.LogError(ex, "An unexpected error occurred in ProfileWorker. Work count: {workCount}", _workCount);
}
await Task.Delay(_options.IntervalMS, stoppingToken);
}
}
}

View File

@@ -0,0 +1,15 @@
namespace ECMJobRunner.WebCron.ProfileWorker;
/// <summary>
/// Configuration options for ProfileWorker background service.
/// </summary>
public class ProfileWorkerOptions
{
public const string SectionName = "ProfileWorker";
/// <summary>
/// Interval in milliseconds between profile synchronization checks.
/// Default: 1000ms (1 second)
/// </summary>
public int IntervalMS { get; set; } = 1000;
}