Public Access
257 lines
11 KiB
C#
257 lines
11 KiB
C#
using DodoSSH.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Microsoft.Extensions.Options;
|
|
using Npgsql;
|
|
|
|
namespace DodoSSH.Api.Setup;
|
|
|
|
/// <summary>Database wiring.</summary>
|
|
internal static class Persistence
|
|
{
|
|
private const string ConnectionStringName = "Postgres";
|
|
|
|
/// <summary>
|
|
/// Advisory lock every instance takes before migrating.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The value is arbitrary and only has to be the same in every replica and different from whatever
|
|
/// else might lock in this database. Spelling "MIGR" in the low bytes makes the <c>pg_locks</c> row
|
|
/// recognisable to whoever is looking at a stuck deployment at the time.
|
|
/// </remarks>
|
|
private const long MigrationLockKey = 0x0D0D_0555_4D49_4752;
|
|
|
|
internal static IServiceCollection AddDodoPersistence(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
var connectionString = configuration.GetConnectionString(ConnectionStringName)
|
|
?? throw new InvalidOperationException(
|
|
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
|
|
|
services.AddDbContext<DodoDbContext>((provider, options) => options
|
|
.UseNpgsql(connectionString, npgsql => npgsql
|
|
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
|
// Retry on transient faults only. A write transaction replayed by the strategy runs on
|
|
// the same context, which still holds what the failed attempt tracked — every such
|
|
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
|
|
// the database instead.
|
|
.EnableRetryOnFailure(3))
|
|
.UseSnakeCaseNamingConvention()
|
|
// None in production. The seam a test uses to make a commit fail the way a dropped
|
|
// connection would, which is the only way to watch a retry happen.
|
|
.AddInterceptors(provider.GetServices<IInterceptor>()));
|
|
|
|
services.AddHealthChecks()
|
|
.AddCheck<DatabaseHealthCheck>(
|
|
"postgres",
|
|
tags: [HealthChecks.ReadyTag, HealthChecks.StartupTag]);
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Brings the schema up to date, before the first request rather than during it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Ordered ahead of <c>RunAsync</c> deliberately: a migration that ran in the background would let
|
|
/// the first requests through against a half-applied schema, and those are precisely the requests
|
|
/// that provision accounts. Failing to migrate therefore fails to start, which is the loudest signal
|
|
/// available and the one an orchestrator already knows how to act on.
|
|
/// </para>
|
|
/// <para>
|
|
/// See <see cref="DatabaseOptions.AutoMigrate"/> for the deployments that should switch this off.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static async Task<WebApplication> MigrateDodoDatabaseAsync(this WebApplication app)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(app);
|
|
|
|
var logger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("DodoSSH.Startup");
|
|
|
|
if (!app.Services.GetRequiredService<IOptions<DatabaseOptions>>().Value.AutoMigrate)
|
|
{
|
|
StartupLog.AutoMigrateDisabled(logger);
|
|
|
|
return app;
|
|
}
|
|
|
|
var cancellationToken = app.Lifetime.ApplicationStopping;
|
|
|
|
var scope = app.Services.CreateAsyncScope();
|
|
await using var scopeHandle = scope.ConfigureAwait(false);
|
|
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>().Database;
|
|
|
|
// A connection of its own, held open across the whole migration. A session-level advisory lock
|
|
// belongs to the connection that took it and EF opens and closes one per command, so borrowing
|
|
// its connection would drop the lock somewhere in the middle.
|
|
var guard = await MigrationGuard
|
|
.AcquireAsync(database.GetConnectionString(), logger, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await using var guardHandle = guard.ConfigureAwait(false);
|
|
|
|
var pending = await database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false);
|
|
var pendingList = pending.ToList();
|
|
|
|
// Reported even when there is nothing to do. Two replicas starting together both say something,
|
|
// and "up to date" from the second is what shows the lock worked rather than that it was skipped.
|
|
if (pendingList.Count == 0)
|
|
{
|
|
StartupLog.SchemaUpToDate(logger);
|
|
|
|
return app;
|
|
}
|
|
|
|
// Named rather than joined inside the call, matching the health check below: the analyzer cannot
|
|
// see through a static wrapper to a guard, and a join over a handful of names once per process
|
|
// start is not worth the shape needed to convince it otherwise.
|
|
var pendingDescription = string.Join(", ", pendingList);
|
|
|
|
StartupLog.ApplyingMigrations(logger, pendingList.Count, pendingDescription);
|
|
|
|
await database.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
StartupLog.MigrationsApplied(logger, pendingList.Count);
|
|
|
|
return app;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Holds the advisory lock that makes concurrent starts safe.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Without it, two replicas rolled out together run <c>MigrateAsync</c> at the same moment: each
|
|
/// reads the same empty history table, both apply the same migration, and the second fails on an
|
|
/// object that already exists — a crash loop on the day of a schema change, which is the worst day
|
|
/// for one. PostgreSQL grants the lock to one of them and blocks the other until it is done, at
|
|
/// which point the second finds nothing pending and starts serving.
|
|
/// </remarks>
|
|
private sealed class MigrationGuard(NpgsqlConnection? connection) : IAsyncDisposable
|
|
{
|
|
internal static async Task<MigrationGuard> AcquireAsync(
|
|
string? connectionString,
|
|
ILogger logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var connection = new NpgsqlConnection(connectionString);
|
|
|
|
try
|
|
{
|
|
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (PostgresException exception)
|
|
when (string.Equals(
|
|
exception.SqlState,
|
|
PostgresErrorCodes.InvalidCatalogName,
|
|
StringComparison.Ordinal))
|
|
{
|
|
// Nothing to lock against yet: the database itself is missing, and EF's own Migrate
|
|
// creates it. Racing here would have one replica win the CREATE DATABASE and the other
|
|
// restart into the ordinary locked path, which is a worse first boot than it looks and
|
|
// still converges — whereas refusing to start would leave a fresh deployment stuck on a
|
|
// step this setting exists to remove.
|
|
await connection.DisposeAsync().ConfigureAwait(false);
|
|
|
|
StartupLog.MigratingUnlockedDatabase(logger);
|
|
|
|
return new MigrationGuard(null);
|
|
}
|
|
catch
|
|
{
|
|
await connection.DisposeAsync().ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
|
|
var command = new NpgsqlCommand("SELECT pg_advisory_lock($1)", connection);
|
|
await using var commandHandle = command.ConfigureAwait(false);
|
|
|
|
command.Parameters.Add(new NpgsqlParameter { Value = MigrationLockKey });
|
|
|
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return new MigrationGuard(connection);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (connection is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Released explicitly rather than left to the connection closing. Both free the lock, but
|
|
// only this one does it at a point in the code where a failure is visible — and an
|
|
// unreleased migration lock is a deployment that hangs on its next start with no clue why.
|
|
try
|
|
{
|
|
var command = new NpgsqlCommand("SELECT pg_advisory_unlock($1)", connection);
|
|
await using var commandHandle = command.ConfigureAwait(false);
|
|
|
|
command.Parameters.Add(new NpgsqlParameter { Value = MigrationLockKey });
|
|
|
|
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
await connection.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reports the database reachable and the schema current.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Pending migrations make the instance unready rather than crashing it. Startup normally applies them
|
|
/// — see <see cref="Persistence.MigrateDodoDatabaseAsync"/> — so reaching this check with migrations
|
|
/// still pending means either that <see cref="DatabaseOptions.AutoMigrate"/> is off and whatever owns
|
|
/// the schema has not run, or that the schema moved under a live instance. The right answer to both is
|
|
/// to stop taking traffic and say so loudly rather than to attempt a repair mid-flight.
|
|
/// </remarks>
|
|
internal sealed class DatabaseHealthCheck(DodoDbContext context, ILogger<DatabaseHealthCheck> logger)
|
|
: IHealthCheck
|
|
{
|
|
/// <inheritdoc />
|
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
|
HealthCheckContext healthCheckContext,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
if (!await context.Database.CanConnectAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
return HealthCheckResult.Unhealthy("Cannot connect to PostgreSQL.");
|
|
}
|
|
|
|
var pending = await context.Database
|
|
.GetPendingMigrationsAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var pendingList = pending.ToList();
|
|
if (pendingList.Count > 0)
|
|
{
|
|
// Computed once and used for both the log and the health result, so this is not
|
|
// work done only for logging.
|
|
var pendingDescription = string.Join(", ", pendingList);
|
|
|
|
StartupLog.PendingMigrations(logger, pendingList.Count, pendingDescription);
|
|
|
|
return HealthCheckResult.Unhealthy(
|
|
$"{pendingList.Count} migration(s) pending: {pendingDescription}");
|
|
}
|
|
|
|
return HealthCheckResult.Healthy();
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
return HealthCheckResult.Unhealthy("PostgreSQL health check failed.", exception);
|
|
}
|
|
}
|
|
}
|