From b703b0c64daf91c06bd5f19f792edacc3c8ffd35 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 18 Sep 2026 16:11:14 +0200 Subject: [PATCH] Added service cache feature flag Updated scheduler and moved to its own dedicated database --- PostFundManagement.Api/Program.cs | 8 ++- .../Extensions/Quartz.cs | 71 ++++++++----------- .../Health/QuartzHealthCheck.cs | 8 +-- .../Quartz/JobOrchestrator.cs | 13 ++-- .../Quartz/MediatorJob.cs | 5 +- .../Quartz/RetryJobListener.cs | 19 ++++- docker-compose.yml | 1 + 7 files changed, 68 insertions(+), 57 deletions(-) diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index 06a25e7..2d378b6 100644 --- a/PostFundManagement.Api/Program.cs +++ b/PostFundManagement.Api/Program.cs @@ -1,12 +1,14 @@ +using Microsoft.FeatureManagement; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Filters; -using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Health; using PostFundManagement.Domain.Mediator; using static PostFundManagement.Domain.Extensions.Constants; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFeatureManagement(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddApiVersioning(options => { @@ -120,9 +122,9 @@ builder.Services.AddCors(options => var app = builder.Build(); var schedulerFactory = app.Services.GetRequiredService(); -var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName); +var scheduler = await schedulerFactory.GetScheduler(); -if (!scheduler!.IsStarted) +if (scheduler!.Status != SchedulerStatus.Running) await scheduler.Start(); app.UseHsts(); diff --git a/PostFundManagement.Application/Extensions/Quartz.cs b/PostFundManagement.Application/Extensions/Quartz.cs index a77bcc1..31e8e51 100644 --- a/PostFundManagement.Application/Extensions/Quartz.cs +++ b/PostFundManagement.Application/Extensions/Quartz.cs @@ -2,7 +2,7 @@ using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Quartz; using static PostFundManagement.Domain.Extensions.Constants; -namespace PostFundManagement.Domain.Extensions; +namespace PostFundManagement.Application.Extensions; public static class Quartz { @@ -10,35 +10,33 @@ public static class Quartz { var connectionString = configuration.GetConnectionString(DatabaseConfigName); + services.Configure(options => + { + options.Properties["quartz.scheduler.instanceName"] = schedulerName; + options.Properties["quartz.scheduler.instanceId"] = "AUTO"; + }); + services.ConfigureCommon(); services.AddQuartz(config => { - config.SchedulerName = schedulerName; - config.SchedulerId = "AUTO"; - config.UseSimpleTypeLoader(); config.UseDefaultThreadPool(options => options.MaxConcurrency = 0); config.UseTimeZoneConverter(); config.UsePersistentStore(storage => { - storage.PerformSchemaValidation = false; + storage.ProvisionSchema(); + storage.UsePostgres(options => options.ConnectionString = connectionString!); storage.UseSystemTextJsonSerializer(); - storage.SetProperty("quartz.jobStore.clustered", "true"); - - storage.UsePostgres(options => - { - options.ConnectionString = connectionString!; - options.TablePrefix = "quartz.qrtz_"; - }); + storage.UseClustering(cluster => { cluster.CheckinInterval = TimeSpan.FromSeconds(30); cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(90); }); - }); + }); }); return services; @@ -48,36 +46,37 @@ public static class Quartz { var connectionString = configuration.GetConnectionString(DatabaseConfigName); + services.Configure(options => + { + options.Properties["quartz.scheduler.instanceName"] = schedulerName; + options.Properties["quartz.scheduler.instanceId"] = "AUTO"; + options.Properties["quartz.scheduler.batchTriggerAcquisitionMaxCount"] = "5"; + options.Properties["quartz.jobStore.misfireThreshold"] = TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture); + + options.Properties["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins"; + options.Properties["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins"; + }); + services.ConfigureCommon(); - services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + services.AddQuartzHostedService(options => + { + options.WaitForJobsToComplete = true; + }); services.AddQuartz(config => { - config.SchedulerName = schedulerName; - config.SchedulerId = "AUTO"; - config.InterruptJobsOnShutdown = true; - config.InterruptJobsOnShutdownWithWait = true; - config.MaxBatchSize = 5; - config.UseSimpleTypeLoader(); - config.UseDefaultThreadPool(options => options.MaxConcurrency = 1); + config.UseDefaultThreadPool(options => options.MaxConcurrency = 5); config.UseTimeZoneConverter(); - config.SetProperty("quartz.jobStore.misfireThreshold", TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture)); - config.UsePersistentStore(storage => { - storage.PerformSchemaValidation = false; + storage.ProvisionSchema(); + storage.UsePostgres(options => options.ConnectionString = connectionString!); storage.UseSystemTextJsonSerializer(); - storage.SetProperty("quartz.jobStore.clustered", "true"); - storage.UsePostgres(options => - { - options.ConnectionString = connectionString!; - options.TablePrefix = "quartz.qrtz_"; - }); storage.UseClustering(cluster => { cluster.CheckinInterval = TimeSpan.FromSeconds(30); @@ -91,18 +90,10 @@ public static class Quartz private static IServiceCollection ConfigureCommon(this IServiceCollection services) { - services.Configure(options => - { - options.Scheduling.IgnoreDuplicates = true; - options.Scheduling.OverWriteExistingData = true; - - options["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins"; - options["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins"; - }); - + services.Configure(options => options.Scheduling.IgnoreDuplicates = true); services.AddTransient(); services.AddTransient(); - + return services; } } \ No newline at end of file diff --git a/PostFundManagement.Domain/Health/QuartzHealthCheck.cs b/PostFundManagement.Domain/Health/QuartzHealthCheck.cs index c11ffdc..e66b880 100644 --- a/PostFundManagement.Domain/Health/QuartzHealthCheck.cs +++ b/PostFundManagement.Domain/Health/QuartzHealthCheck.cs @@ -8,15 +8,15 @@ public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHea { try { - var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName, cancellationToken); + var scheduler = await schedulerFactory.GetScheduler(cancellationToken); - if(scheduler == null) + if (scheduler == null) return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found."); - if (!scheduler.IsStarted) + if (scheduler.Status != SchedulerStatus.Running) return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running"); - await scheduler.CheckExists(new JobKey(Guid.NewGuid().ToString()), cancellationToken); + await scheduler.Exists(new JobKey(Guid.NewGuid().ToString()), cancellationToken); return HealthCheckResult.Healthy($"{DefaultSchedulerName} Quartz scheduler is ready"); } diff --git a/PostFundManagement.Domain/Quartz/JobOrchestrator.cs b/PostFundManagement.Domain/Quartz/JobOrchestrator.cs index 48cb645..00a2a39 100644 --- a/PostFundManagement.Domain/Quartz/JobOrchestrator.cs +++ b/PostFundManagement.Domain/Quartz/JobOrchestrator.cs @@ -26,7 +26,7 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr .StartNow() .Build(); - await scheduler.ScheduleJob(job, new List { trigger }.AsReadOnly(), replace: true, cancellationToken); + await scheduler.ScheduleJob(job, new List { trigger }.AsReadOnly(), new ScheduleJobOptions { Replace = true }, cancellationToken); } public async ValueTask ScheduleAsync(TNotification notification, string cronExpression, CancellationToken cancellationToken = default) @@ -51,17 +51,16 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr var trigger = global::Quartz.TriggerBuilder.Create() .WithIdentity(triggerKey) .WithDescription($"Scheduled via Main Job at {now:g}") - .WithCronSchedule(cronExpression, cron => cron - .WithMisfireHandlingInstructionIgnoreMisfires()) + .WithCronSchedule(cronExpression, cron => cron.WithMisfireInstruction(CronTriggerMisfireInstruction.IgnoreMisfires)) .StartAt((DateTimeOffset)now) .Build(); - await scheduler.AddJob(job, replace: true, cancellationToken); + await scheduler.AddJob(job, new AddJobOptions { Replace = true }, cancellationToken); - if (await scheduler.CheckExists(triggerKey, cancellationToken)) + if (await scheduler.Exists(triggerKey, cancellationToken)) await scheduler.RescheduleJob(triggerKey, trigger, cancellationToken); else - await scheduler.ScheduleJob(job, new List { trigger }.AsReadOnly(), replace: true, cancellationToken); + await scheduler.ScheduleJob(job, new List { trigger }.AsReadOnly(), new ScheduleJobOptions { Replace = true }, cancellationToken); } public async ValueTask InterruptAsync(string eventName, string? correlationId = null, CancellationToken cancellationToken = default) @@ -82,6 +81,6 @@ public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOr jobGroup = "scheduled-jobs"; } - return await scheduler.Interrupt(JobKey.Create(jobKeyName, jobGroup), cancellationToken); + return await scheduler.Interrupt(new JobKey(jobKeyName, jobGroup), cancellationToken); } } \ No newline at end of file diff --git a/PostFundManagement.Domain/Quartz/MediatorJob.cs b/PostFundManagement.Domain/Quartz/MediatorJob.cs index f42c2d5..730a46f 100644 --- a/PostFundManagement.Domain/Quartz/MediatorJob.cs +++ b/PostFundManagement.Domain/Quartz/MediatorJob.cs @@ -7,7 +7,8 @@ namespace PostFundManagement.Domain.Quartz; [DisallowConcurrentExecution] public sealed class MediatorJob(IMediator mediator) : IJob where TNotification : IEvent { - public async Task Execute(IJobExecutionContext context) + + public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) { if (context.Recovering) Trace.WriteLine($"CRITICAL RECOVERY: Resurrecting job '{typeof(TNotification).Name}' after a previous cluster node crashed mid-execution."); @@ -43,7 +44,7 @@ public sealed class MediatorJob(IMediator mediator) : IJob where catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested) { Trace.WriteLine($"Job '{typeof(TNotification).Name}' was gracefully interrupted by the cluster control plane."); - + activity?.SetStatus(ActivityStatusCode.Ok); return; diff --git a/PostFundManagement.Domain/Quartz/RetryJobListener.cs b/PostFundManagement.Domain/Quartz/RetryJobListener.cs index 9565773..8109c96 100644 --- a/PostFundManagement.Domain/Quartz/RetryJobListener.cs +++ b/PostFundManagement.Domain/Quartz/RetryJobListener.cs @@ -16,6 +16,23 @@ public sealed class RetryJobListener : IJobListener return; if (jobException is not null && context.RefireCount < RetryCount) - jobException.RefireImmediately = true; + { + if (context.JobDetail is null) + return; + + var triggers = await context.Scheduler.GetTriggersOfJob(context.JobDetail.Key, cancellationToken); + var oldTrigger = triggers?.FirstOrDefault(); + + if (oldTrigger is null) + return; + + var newTrigger = global::Quartz.TriggerBuilder.Create() + .WithIdentity($"{oldTrigger.Key.Name}-retry-{context.RefireCount}", oldTrigger.Key.Group) + .StartNow() + .ForJob(context.JobDetail.Key) + .Build(); + + await context.Scheduler.RescheduleJob(oldTrigger.Key, newTrigger, cancellationToken); + } } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9cd2552..23c92e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: # Postgres Database Overrides - ConnectionStrings__PfmDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-pfm};User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD}; + - ConnectionStrings__PfmSchedulerDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=pfm-scheduler;User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD}; # Garage S3 Settings - GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-https://s3.garage.khongisa.co.za}