// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using Microsoft.Extensions.Diagnostics.HealthChecks; using Npgsql; namespace Progmasoft.Progmaweb.Api.Accounts; /// Which a configuration selects. internal enum AccountStoreKind { /// : nothing survives a restart. Memory, /// : accounts and sessions survive a restart. PostgreSql } /// Chooses the account store from the configuration and registers what belongs to it. /// /// /// ConnectionStrings:Accounts names the PostgreSQL database. Accounts:Store may name the store /// outright, as PostgreSQL or Memory. Without it, a connection string selects PostgreSQL. /// /// /// A production service that is given neither does not start. Keeping accounts in memory there has to be asked for /// with Accounts:Store=Memory, because a service that quietly forgets every account at each restart is the /// failure this store exists to prevent. Outside production the default without a connection string is memory. /// /// internal static class AccountStoreRegistration { /// Writes the warning of . private static readonly Action InMemoryWarning = LoggerMessage.Define( LogLevel.Warning, new EventId(1, "AccountsInMemory"), "Accounts are kept in memory: every account and session is lost when this service stops."); /// Name of the connection string of the account database. public const string ConnectionStringName = "Accounts"; /// Configuration key that names the store outright. public const string StoreKey = "Accounts:Store"; /// Decides which store a configuration selects. /// The configuration of the service. /// Whether the service runs in the production environment. /// The selected store. /// /// The store is named with an unknown word, PostgreSQL is named without a connection string, memory is named /// with one, or a production service is given neither. /// public static AccountStoreKind Select(IConfiguration configuration, bool isProduction) { string? named = configuration[StoreKey]; bool hasConnectionString = !string.IsNullOrWhiteSpace(configuration.GetConnectionString(ConnectionStringName)); if (string.IsNullOrWhiteSpace(named)) { if (hasConnectionString) { return AccountStoreKind.PostgreSql; } return isProduction ? throw new InvalidOperationException( $"No account database is configured. Set ConnectionStrings:{ConnectionStringName} to the " + $"PostgreSQL database of the accounts, or set {StoreKey}=Memory to accept that every account " + "and session is lost when the service restarts.") : AccountStoreKind.Memory; } if (string.Equals(named, "PostgreSQL", StringComparison.OrdinalIgnoreCase)) { return hasConnectionString ? AccountStoreKind.PostgreSql : throw new InvalidOperationException( $"{StoreKey} is PostgreSQL, and ConnectionStrings:{ConnectionStringName} is not set."); } if (string.Equals(named, "Memory", StringComparison.OrdinalIgnoreCase)) { // Both together would leave a configured database unused without anyone noticing. return hasConnectionString ? throw new InvalidOperationException( $"{StoreKey} is Memory, and ConnectionStrings:{ConnectionStringName} is set. Remove one of them.") : AccountStoreKind.Memory; } throw new InvalidOperationException($"{StoreKey} is '{named}'. It must be PostgreSQL or Memory."); } /// Registers the selected store, and for PostgreSQL its schema step, health check and connections. /// The services of the application. /// The configuration of the service. /// Whether the service runs in the production environment. /// The selected store, so that the caller can report it. public static AccountStoreKind AddAccountStore( this IServiceCollection services, IConfiguration configuration, bool isProduction) { AccountStoreKind kind = Select(configuration, isProduction); IHealthChecksBuilder health = services.AddHealthChecks(); if (kind is AccountStoreKind.Memory) { services.AddSingleton(); } else { string connectionString = configuration.GetConnectionString(ConnectionStringName)!; // The data source owns the connection pool; the container disposes it when the service stops. services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString)); services.AddSingleton(); // Registered before the session cleanup, so the tables exist before anything reads them. services.AddHostedService(); health.AddCheck("account-database"); } services.AddHostedService(); return kind; } /// Writes to the log that this service forgets its accounts when it stops. /// The log of the service. public static void WarnThatAccountsAreInMemory(ILogger logger) => InMemoryWarning(logger, null); } /// Brings the account database to the current schema before the service accepts requests. /// /// A database that cannot be reached, or that is newer than the program, stops the start of the service. A service /// that started without its tables would answer every account request with an error instead. /// /// Connections to the account database. /// Receives what was done. internal sealed class AccountSchemaInitializer( NpgsqlDataSource dataSource, ILogger logger) : IHostedService { /// Writes the schema version and the number of steps taken at this start. private static readonly Action SchemaReport = LoggerMessage.Define( LogLevel.Information, new EventId(2, "AccountSchema"), "The account database is at schema version {Version}; {Taken} step(s) were taken at this start."); /// public async Task StartAsync(CancellationToken cancellationToken) { int taken = await AccountSchema.ApplyAsync(dataSource, cancellationToken); SchemaReport(logger, AccountSchema.CurrentVersion, taken, null); } /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } /// Reports whether the account database answers. /// Connections to the account database. internal sealed class AccountDatabaseHealthCheck(NpgsqlDataSource dataSource) : IHealthCheck { /// public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { try { await using NpgsqlCommand command = dataSource.CreateCommand("SELECT 1"); await command.ExecuteScalarAsync(cancellationToken); return HealthCheckResult.Healthy(); } catch (Exception exception) when (exception is NpgsqlException or TimeoutException or IOException) { // The description is written to the log of the service, never to the response of /health. return HealthCheckResult.Unhealthy("The account database does not answer.", exception); } } } /// Removes sessions that have expired, once at the start and then once an hour. /// /// An expired session is already refused when its token is presented, and removed then. A token that is never /// presented again would otherwise keep its row for ever; this removes those. A removal that fails is logged and /// tried again at the next hour, because the service works without it. /// /// The store whose sessions are removed. /// Clock that decides what has expired and paces the removals. /// Receives what was done. internal sealed class ExpiredSessionCleanup( IAccountStore store, TimeProvider timeProvider, ILogger logger) : BackgroundService { /// Writes how many expired sessions a removal took. private static readonly Action RemovedReport = LoggerMessage.Define( LogLevel.Information, new EventId(3, "ExpiredSessionsRemoved"), "Removed {Count} expired session(s)."); /// Writes that a removal failed, with its cause. private static readonly Action FailureReport = LoggerMessage.Define( LogLevel.Warning, new EventId(4, "ExpiredSessionsNotRemoved"), "Expired sessions could not be removed; the next hour tries again."); /// Time between two removals. internal static readonly TimeSpan Interval = TimeSpan.FromHours(1); /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using PeriodicTimer timer = new(Interval, timeProvider); do { await RemoveOnceAsync(stoppingToken); } while (await WaitAsync(timer, stoppingToken)); } /// Removes what has expired by now. /// Cancels the operation. /// A task that completes when the removal has run or has failed. internal async Task RemoveOnceAsync(CancellationToken cancellationToken) { try { int removed = await store.RemoveExpiredSessionsAsync(timeProvider.GetUtcNow(), cancellationToken); if (removed != 0) { RemovedReport(logger, removed, null); } } catch (Exception exception) when (exception is not OperationCanceledException) { FailureReport(logger, exception); } } /// Waits for the next tick; stopping the service ends the wait without an error. /// The timer that paces the removals. /// Signals that the service stops. /// at a tick, when the service stops. private static async Task WaitAsync(PeriodicTimer timer, CancellationToken stoppingToken) { try { return await timer.WaitForNextTickAsync(stoppingToken); } catch (OperationCanceledException) { return false; } } }