From ab2ef9da313e96a4fd9e11f1f9415aaf9e4b741a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 21 Aug 2026 11:19:06 +0200 Subject: [PATCH] Added healthchecks Configured scheduler schema Added dataprotection support --- .gitignore | 2 + .../Extensions/Postgres.cs | 3 +- .../Extensions/{Api.cs => Security.cs} | 4 +- PostFundManagement.Api/Program.cs | 13 +- .../Extensions/Quartz.cs | 18 +- .../Health/PostgresHealthCheck.cs | 28 +++ .../Health/QuartzHealthCheck.cs | 28 +++ .../Database/quartz_tables.sql | 193 ++++++++++++++++++ 8 files changed, 275 insertions(+), 14 deletions(-) rename {PostFundManagement.Infrastructure => PostFundManagement.Api}/Extensions/Postgres.cs (92%) rename PostFundManagement.Api/Extensions/{Api.cs => Security.cs} (98%) create mode 100644 PostFundManagement.Domain/Health/PostgresHealthCheck.cs create mode 100644 PostFundManagement.Domain/Health/QuartzHealthCheck.cs create mode 100644 PostFundManagement.Infrastructure/Database/quartz_tables.sql diff --git a/.gitignore b/.gitignore index d7bd59d..db1a675 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ bld/ # Visual Studio 2015/2017 cache/options directory .vs/ .vscode/ +bin/ +certificates/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ diff --git a/PostFundManagement.Infrastructure/Extensions/Postgres.cs b/PostFundManagement.Api/Extensions/Postgres.cs similarity index 92% rename from PostFundManagement.Infrastructure/Extensions/Postgres.cs rename to PostFundManagement.Api/Extensions/Postgres.cs index c12d4b5..e549daa 100644 --- a/PostFundManagement.Infrastructure/Extensions/Postgres.cs +++ b/PostFundManagement.Api/Extensions/Postgres.cs @@ -1,7 +1,8 @@ +using Microsoft.EntityFrameworkCore; using PostFundManagement.Infrastructure.Database; using static PostFundManagement.Domain.Extensions.Constants; -namespace PostFundManagement.Infrastructure.Extensions; +namespace PostFundManagement.Api.Extensions; public static class Postgres { diff --git a/PostFundManagement.Api/Extensions/Api.cs b/PostFundManagement.Api/Extensions/Security.cs similarity index 98% rename from PostFundManagement.Api/Extensions/Api.cs rename to PostFundManagement.Api/Extensions/Security.cs index 2f3c3f5..d638f7d 100644 --- a/PostFundManagement.Api/Extensions/Api.cs +++ b/PostFundManagement.Api/Extensions/Security.cs @@ -6,7 +6,7 @@ using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Api.Extensions; -public static class Api +public static class Security { public static IServiceCollection AddSecurityApiSdk(this IServiceCollection services, IConfiguration configuration) { @@ -42,6 +42,8 @@ public static class Api public static IServiceCollection AddWebSecurity(this IServiceCollection services, IConfiguration configuration) { + services.AddAuthorization(); + var certString = configuration["DataProtection:Certificate"] ?? configuration["DataProtection__Certificate"]; var certPassword = configuration["DataProtection:Password"] ?? configuration["DataProtection__Password"]; diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index 4f84139..eb6749b 100644 --- a/PostFundManagement.Api/Program.cs +++ b/PostFundManagement.Api/Program.cs @@ -1,7 +1,8 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; using PostFundManagement.Api.Extensions; using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Health; using PostFundManagement.Domain.Mediator; -using PostFundManagement.Infrastructure.Extensions; using static PostFundManagement.Domain.Extensions.Constants; var builder = WebApplication.CreateBuilder(args); @@ -21,7 +22,7 @@ builder.Services.AddWebSecurity(builder.Configuration); builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>)); builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>)); -builder.Services.AddQuartzSchedulerClient(DefaultSchedulerName, builder.Configuration); +builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration); builder.Services.AddEmailServices(builder.Configuration); @@ -30,10 +31,10 @@ builder.Services.AddHashServices(builder.Configuration); builder.Services.AddDataProtectionDatabase(builder.Configuration); builder.Services.AddApplicationDbContext(builder.Configuration); -// TODO: add healthcheck services -// builder.Services.AddMidrandShopPostgresHealthCheck(); -// builder.Services.AddMidrandShopQuartzHealthCheck(); -// builder.Services.AddHealthChecksSupport(builder.Configuration); +builder.Services.AddHealthChecks() + .AddCheck("QuartzScheduler") + .AddCheck("PostgresDatabase") + .AddCheck("Self", () => HealthCheckResult.Healthy()); var app = builder.Build(); diff --git a/PostFundManagement.Domain/Extensions/Quartz.cs b/PostFundManagement.Domain/Extensions/Quartz.cs index e52d909..a77bcc1 100644 --- a/PostFundManagement.Domain/Extensions/Quartz.cs +++ b/PostFundManagement.Domain/Extensions/Quartz.cs @@ -27,15 +27,18 @@ public static class Quartz storage.UseSystemTextJsonSerializer(); storage.SetProperty("quartz.jobStore.clustered", "true"); - storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_"); - - storage.UsePostgres(connectionString!); + + 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; @@ -69,9 +72,12 @@ public static class Quartz storage.UseSystemTextJsonSerializer(); storage.SetProperty("quartz.jobStore.clustered", "true"); - storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_"); - storage.UsePostgres(connectionString!); + storage.UsePostgres(options => + { + options.ConnectionString = connectionString!; + options.TablePrefix = "quartz.qrtz_"; + }); storage.UseClustering(cluster => { cluster.CheckinInterval = TimeSpan.FromSeconds(30); diff --git a/PostFundManagement.Domain/Health/PostgresHealthCheck.cs b/PostFundManagement.Domain/Health/PostgresHealthCheck.cs new file mode 100644 index 0000000..39b58ce --- /dev/null +++ b/PostFundManagement.Domain/Health/PostgresHealthCheck.cs @@ -0,0 +1,28 @@ +using static PostFundManagement.Domain.Extensions.Constants; + +namespace PostFundManagement.Domain.Health; + +public sealed class PostgresHealthCheck(IConfiguration configuration) : IHealthCheck +{ + private readonly string connectionString = configuration.GetConnectionString(DatabaseConfigName)!; + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + await using var dataSource = NpgsqlDataSource.Create(connectionString); + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1"; + + await command.ExecuteScalarAsync(cancellationToken); + + return HealthCheckResult.Healthy($"{DatabaseConfigName} is responsive."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy($"{DatabaseConfigName} is unreachable.", ex); + } + } +} \ No newline at end of file diff --git a/PostFundManagement.Domain/Health/QuartzHealthCheck.cs b/PostFundManagement.Domain/Health/QuartzHealthCheck.cs new file mode 100644 index 0000000..c11ffdc --- /dev/null +++ b/PostFundManagement.Domain/Health/QuartzHealthCheck.cs @@ -0,0 +1,28 @@ +using static PostFundManagement.Domain.Extensions.Constants; + +namespace PostFundManagement.Domain.Health; + +public sealed class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var scheduler = await schedulerFactory.GetScheduler(DefaultSchedulerName, cancellationToken); + + if(scheduler == null) + return HealthCheckResult.Unhealthy($"Scheduler with name '{DefaultSchedulerName}' not found."); + + if (!scheduler.IsStarted) + return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler is not running"); + + await scheduler.CheckExists(new JobKey(Guid.NewGuid().ToString()), cancellationToken); + + return HealthCheckResult.Healthy($"{DefaultSchedulerName} Quartz scheduler is ready"); + } + catch (SchedulerException) + { + return HealthCheckResult.Unhealthy($"{DefaultSchedulerName} Quartz scheduler cannot connect to the store"); + } + } +} \ No newline at end of file diff --git a/PostFundManagement.Infrastructure/Database/quartz_tables.sql b/PostFundManagement.Infrastructure/Database/quartz_tables.sql new file mode 100644 index 0000000..39002d2 --- /dev/null +++ b/PostFundManagement.Infrastructure/Database/quartz_tables.sql @@ -0,0 +1,193 @@ +-- This script is for PostgreSQL + +-- This initializes the database to pristine for Quartz, by first removing any existing Quartz tables +-- and then recreating them from scratch. +-- Should you only require it to create the tables, set DropDb to 0. +-- 1. Create the dedicated schema +CREATE SCHEMA IF NOT EXISTS quartz; + +-- 2. Set the search path so the creation script targets this schema +SET search_path TO quartz; + +DO $$ + DECLARE DropDb INT := 1; -- Set this to 0 to skip DROP statements, 1 to include them +BEGIN + IF DropDb = 1 THEN + SET client_min_messages = WARNING; + DROP TABLE IF EXISTS qrtz_fired_triggers; + DROP TABLE IF EXISTS qrtz_paused_trigger_grps; + DROP TABLE IF EXISTS qrtz_scheduler_state; + DROP TABLE IF EXISTS qrtz_locks; + DROP TABLE IF EXISTS qrtz_simprop_triggers; + DROP TABLE IF EXISTS qrtz_simple_triggers; + DROP TABLE IF EXISTS qrtz_cron_triggers; + DROP TABLE IF EXISTS qrtz_blob_triggers; + DROP TABLE IF EXISTS qrtz_triggers; + DROP TABLE IF EXISTS qrtz_job_details; + DROP TABLE IF EXISTS qrtz_calendars; + SET client_min_messages = NOTICE; + END IF; +END $$; + +CREATE TABLE qrtz_job_details + ( + sched_name TEXT NOT NULL, + job_name TEXT NOT NULL, + job_group TEXT NOT NULL, + description TEXT NULL, + job_class_name TEXT NOT NULL, + is_durable BOOL NOT NULL, + is_nonconcurrent BOOL NOT NULL, + is_update_data BOOL NOT NULL, + requests_recovery BOOL NOT NULL, + job_data BYTEA NULL, + PRIMARY KEY (sched_name, job_name, job_group) +); + +CREATE TABLE qrtz_triggers + ( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + job_name TEXT NOT NULL, + job_group TEXT NOT NULL, + description TEXT NULL, + next_fire_time BIGINT NULL, + prev_fire_time BIGINT NULL, + priority INTEGER NULL, + trigger_state TEXT NOT NULL, + trigger_type TEXT NOT NULL, + start_time BIGINT NOT NULL, + end_time BIGINT NULL, + calendar_name TEXT NULL, + misfire_instr SMALLINT NULL, + misfire_orig_fire_time BIGINT NULL, + execution_group VARCHAR(200) NULL, + preferred_node VARCHAR(200) NULL, + preferred_node_auto BOOL NOT NULL DEFAULT FALSE, + job_data BYTEA NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, job_name, job_group) + REFERENCES qrtz_job_details (sched_name, job_name, job_group) +); + +CREATE TABLE qrtz_simple_triggers + ( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + repeat_count BIGINT NOT NULL, + repeat_interval BIGINT NOT NULL, + times_triggered BIGINT NOT NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) + ON DELETE CASCADE +); + +CREATE TABLE qrtz_simprop_triggers + ( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + str_prop_1 TEXT NULL, + str_prop_2 TEXT NULL, + str_prop_3 TEXT NULL, + int_prop_1 INTEGER NULL, + int_prop_2 INTEGER NULL, + long_prop_1 BIGINT NULL, + long_prop_2 BIGINT NULL, + dec_prop_1 NUMERIC NULL, + dec_prop_2 NUMERIC NULL, + bool_prop_1 BOOL NULL, + bool_prop_2 BOOL NULL, + time_zone_id TEXT NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) + ON DELETE CASCADE +); + +CREATE TABLE qrtz_cron_triggers + ( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + cron_expression TEXT NOT NULL, + time_zone_id TEXT, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) + ON DELETE CASCADE +); + +CREATE TABLE qrtz_blob_triggers + ( + sched_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + blob_data BYTEA NULL, + PRIMARY KEY (sched_name, trigger_name, trigger_group), + FOREIGN KEY (sched_name, trigger_name, trigger_group) + REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) + ON DELETE CASCADE +); + +CREATE TABLE qrtz_calendars + ( + sched_name TEXT NOT NULL, + calendar_name TEXT NOT NULL, + calendar BYTEA NOT NULL, + PRIMARY KEY (sched_name, calendar_name) +); + +CREATE TABLE qrtz_paused_trigger_grps + ( + sched_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + PRIMARY KEY (sched_name, trigger_group) +); + +CREATE TABLE qrtz_fired_triggers + ( + sched_name TEXT NOT NULL, + entry_id TEXT NOT NULL, + trigger_name TEXT NOT NULL, + trigger_group TEXT NOT NULL, + instance_name TEXT NOT NULL, + fired_time BIGINT NOT NULL, + sched_time BIGINT NOT NULL, + priority INTEGER NOT NULL, + state TEXT NOT NULL, + job_name TEXT NULL, + job_group TEXT NULL, + is_nonconcurrent BOOL NOT NULL, + requests_recovery BOOL NULL, + execution_group VARCHAR(200) NULL, + PRIMARY KEY (sched_name, entry_id) +); + +CREATE TABLE qrtz_scheduler_state + ( + sched_name TEXT NOT NULL, + instance_name TEXT NOT NULL, + last_checkin_time BIGINT NOT NULL, + checkin_interval BIGINT NOT NULL, + PRIMARY KEY (sched_name, instance_name) +); + +CREATE TABLE qrtz_locks + ( + sched_name TEXT NOT NULL, + lock_name TEXT NOT NULL, + PRIMARY KEY (sched_name, lock_name) +); + +CREATE INDEX idx_qrtz_j_g_n ON qrtz_job_details (sched_name, job_group, job_name); +CREATE INDEX idx_qrtz_t_j ON qrtz_triggers (sched_name, job_name, job_group); +CREATE INDEX idx_qrtz_t_c ON qrtz_triggers (sched_name, calendar_name); +CREATE INDEX idx_qrtz_t_g_n ON qrtz_triggers (sched_name, trigger_group, trigger_name); +CREATE INDEX idx_qrtz_t_nft_st ON qrtz_triggers (sched_name, trigger_state, next_fire_time); +CREATE INDEX idx_qrtz_ft_inst_job_req_rcvry ON qrtz_fired_triggers (sched_name, instance_name, requests_recovery); +CREATE INDEX idx_qrtz_ft_j_g ON qrtz_fired_triggers (sched_name, job_name, job_group); +CREATE INDEX idx_qrtz_ft_t_g ON qrtz_fired_triggers (sched_name, trigger_name, trigger_group); \ No newline at end of file