Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

@@ -0,0 +1,181 @@
using System.Reflection;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Interface for running database migrations.
/// </summary>
public interface IMigrationRunner
{
/// <summary>
/// Gets the schema name for this migration runner.
/// </summary>
string SchemaName { get; }
/// <summary>
/// Gets the module name for this migration runner.
/// </summary>
string ModuleName { get; }
/// <summary>
/// Runs pending migrations from the specified path.
/// </summary>
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
/// <param name="options">Migration execution options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of migration execution.</returns>
Task<MigrationResult> RunAsync(
string migrationsPath,
MigrationRunOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Runs pending migrations from embedded resources in an assembly.
/// </summary>
/// <param name="assembly">Assembly containing embedded migration resources.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
/// <param name="options">Migration execution options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of migration execution.</returns>
Task<MigrationResult> RunFromAssemblyAsync(
Assembly assembly,
string? resourcePrefix = null,
MigrationRunOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current migration version (latest applied migration).
/// </summary>
Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all applied migrations.
/// </summary>
Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Validates checksums of applied migrations against source files.
/// </summary>
/// <param name="assembly">Assembly containing embedded migration resources.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of checksum validation errors, empty if all valid.</returns>
Task<IReadOnlyList<string>> ValidateChecksumsAsync(
Assembly assembly,
string? resourcePrefix = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for migration execution.
/// </summary>
public sealed class MigrationRunOptions
{
/// <summary>
/// Filter migrations by category. If null, all categories are included.
/// </summary>
public MigrationCategory? CategoryFilter { get; set; }
/// <summary>
/// If true, only show what would be executed without applying.
/// </summary>
public bool DryRun { get; set; }
/// <summary>
/// Timeout in seconds for individual migration execution. Default: 300.
/// </summary>
public int TimeoutSeconds { get; set; } = 300;
/// <summary>
/// If true, validate checksums before applying new migrations.
/// </summary>
public bool ValidateChecksums { get; set; } = true;
/// <summary>
/// If true, fail if checksum validation errors are found.
/// </summary>
public bool FailOnChecksumMismatch { get; set; } = true;
}
/// <summary>
/// Result of a migration execution.
/// </summary>
public sealed class MigrationResult
{
/// <summary>
/// Whether the migration run was successful.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Number of migrations applied.
/// </summary>
public int AppliedCount { get; init; }
/// <summary>
/// Number of migrations skipped (already applied).
/// </summary>
public int SkippedCount { get; init; }
/// <summary>
/// Number of migrations filtered out by category.
/// </summary>
public int FilteredCount { get; init; }
/// <summary>
/// Total duration in milliseconds.
/// </summary>
public long DurationMs { get; init; }
/// <summary>
/// Details of applied migrations.
/// </summary>
public IReadOnlyList<AppliedMigrationDetail> AppliedMigrations { get; init; } = [];
/// <summary>
/// Checksum validation errors, if any.
/// </summary>
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
/// <summary>
/// Error message if migration failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static MigrationResult Successful(
int appliedCount,
int skippedCount,
int filteredCount,
long durationMs,
IReadOnlyList<AppliedMigrationDetail> appliedMigrations) => new()
{
Success = true,
AppliedCount = appliedCount,
SkippedCount = skippedCount,
FilteredCount = filteredCount,
DurationMs = durationMs,
AppliedMigrations = appliedMigrations
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static MigrationResult Failed(string errorMessage, IReadOnlyList<string>? checksumErrors = null) => new()
{
Success = false,
ErrorMessage = errorMessage,
ChecksumErrors = checksumErrors ?? []
};
}
/// <summary>
/// Details of an applied migration.
/// </summary>
public sealed record AppliedMigrationDetail(
string Name,
MigrationCategory Category,
long DurationMs,
bool WasDryRun);

View File

@@ -0,0 +1,88 @@
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Categorizes migrations by when they should be executed.
/// </summary>
public enum MigrationCategory
{
/// <summary>
/// Automatic migrations that run at application startup.
/// Must be non-breaking and complete quickly (< 60s).
/// Prefix: 001-099
/// </summary>
Startup,
/// <summary>
/// Manual migrations that require CLI execution before deployment.
/// Used for breaking changes that need coordination.
/// Prefix: 100-199
/// </summary>
Release,
/// <summary>
/// Seed data that is inserted once.
/// Prefix: S001-S999
/// </summary>
Seed,
/// <summary>
/// Long-running data migrations that run as background jobs.
/// Prefix: DM001-DM999
/// </summary>
Data
}
/// <summary>
/// Extension methods for migration category operations.
/// </summary>
public static class MigrationCategoryExtensions
{
/// <summary>
/// Determines the category of a migration based on its filename.
/// </summary>
public static MigrationCategory GetCategory(string migrationName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(migrationName);
// Extract numeric prefix
var name = Path.GetFileNameWithoutExtension(migrationName);
if (name.StartsWith("DM", StringComparison.OrdinalIgnoreCase))
{
return MigrationCategory.Data;
}
if (name.StartsWith("S", StringComparison.OrdinalIgnoreCase) && char.IsDigit(name.ElementAtOrDefault(1)))
{
return MigrationCategory.Seed;
}
// Try to parse leading digits
var numericPrefix = new string(name.TakeWhile(char.IsDigit).ToArray());
if (int.TryParse(numericPrefix, out var prefix))
{
return prefix switch
{
>= 1 and <= 99 => MigrationCategory.Startup,
>= 100 and <= 199 => MigrationCategory.Release,
>= 200 => MigrationCategory.Release,
_ => MigrationCategory.Startup
};
}
// Default to startup for unknown patterns
return MigrationCategory.Startup;
}
/// <summary>
/// Returns true if this migration should run automatically at startup.
/// </summary>
public static bool IsAutomatic(this MigrationCategory category) =>
category is MigrationCategory.Startup or MigrationCategory.Seed;
/// <summary>
/// Returns true if this migration requires manual CLI execution.
/// </summary>
public static bool RequiresManualExecution(this MigrationCategory category) =>
category is MigrationCategory.Release or MigrationCategory.Data;
}

View File

@@ -0,0 +1,383 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Extension methods for registering migration services.
/// </summary>
public static class MigrationServiceExtensions
{
/// <summary>
/// Adds a startup migration host for the specified module.
/// </summary>
/// <typeparam name="TOptions">Options type containing the connection string.</typeparam>
/// <param name="services">Service collection.</param>
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
/// <param name="moduleName">Module name for logging.</param>
/// <param name="migrationsAssembly">Assembly containing embedded SQL migrations.</param>
/// <param name="connectionStringSelector">Function to extract connection string from options.</param>
/// <param name="configureOptions">Optional configuration for migration behavior.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddStartupMigrations<TOptions>(
this IServiceCollection services,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
Func<TOptions, string> connectionStringSelector,
Action<StartupMigrationOptions>? configureOptions = null)
where TOptions : class
{
var migrationOptions = new StartupMigrationOptions();
configureOptions?.Invoke(migrationOptions);
services.AddHostedService(sp =>
{
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
var connectionString = connectionStringSelector(options);
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
var lifetime = sp.GetRequiredService<IHostApplicationLifetime>();
return new GenericStartupMigrationHost(
connectionString,
schemaName,
moduleName,
migrationsAssembly,
logger,
lifetime,
migrationOptions);
});
return services;
}
/// <summary>
/// Adds a startup migration host using PostgresOptions.
/// </summary>
public static IServiceCollection AddStartupMigrations(
this IServiceCollection services,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
Action<StartupMigrationOptions>? configureOptions = null)
{
return services.AddStartupMigrations<PostgresOptions>(
schemaName,
moduleName,
migrationsAssembly,
options => options.ConnectionString,
configureOptions);
}
/// <summary>
/// Adds a migration runner as a singleton for manual/CLI migration execution.
/// </summary>
public static IServiceCollection AddMigrationRunner<TOptions>(
this IServiceCollection services,
string schemaName,
string moduleName,
Func<TOptions, string> connectionStringSelector)
where TOptions : class
{
services.AddSingleton(sp =>
{
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
var connectionString = connectionStringSelector(options);
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
return new MigrationRunner(connectionString, schemaName, moduleName, logger);
});
return services;
}
/// <summary>
/// Adds the migration status service for querying migration state.
/// </summary>
public static IServiceCollection AddMigrationStatus<TOptions>(
this IServiceCollection services,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
Func<TOptions, string> connectionStringSelector)
where TOptions : class
{
services.AddSingleton<IMigrationStatusService>(sp =>
{
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
var connectionString = connectionStringSelector(options);
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"MigrationStatus.{moduleName}");
return new MigrationStatusService(
connectionString,
schemaName,
moduleName,
migrationsAssembly,
logger);
});
return services;
}
private sealed class GenericStartupMigrationHost : StartupMigrationHost
{
public GenericStartupMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions options)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}
}
/// <summary>
/// Service for querying migration status without applying migrations.
/// </summary>
public interface IMigrationStatusService
{
/// <summary>
/// Gets the current migration status for the module.
/// </summary>
Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Current migration status for a module.
/// </summary>
public sealed record MigrationStatus
{
/// <summary>
/// Module name.
/// </summary>
public required string ModuleName { get; init; }
/// <summary>
/// Schema name.
/// </summary>
public required string SchemaName { get; init; }
/// <summary>
/// Number of applied migrations.
/// </summary>
public int AppliedCount { get; init; }
/// <summary>
/// Number of pending startup migrations.
/// </summary>
public int PendingStartupCount { get; init; }
/// <summary>
/// Number of pending release migrations.
/// </summary>
public int PendingReleaseCount { get; init; }
/// <summary>
/// Last applied migration name.
/// </summary>
public string? LastAppliedMigration { get; init; }
/// <summary>
/// When the last migration was applied.
/// </summary>
public DateTimeOffset? LastAppliedAt { get; init; }
/// <summary>
/// List of pending migrations.
/// </summary>
public IReadOnlyList<PendingMigrationInfo> PendingMigrations { get; init; } = [];
/// <summary>
/// Any checksum mismatches detected.
/// </summary>
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
/// <summary>
/// Whether the database is up to date (no pending startup/release migrations).
/// </summary>
public bool IsUpToDate => PendingStartupCount == 0 && PendingReleaseCount == 0;
/// <summary>
/// Whether there are blocking issues (pending release migrations or checksum errors).
/// </summary>
public bool HasBlockingIssues => PendingReleaseCount > 0 || ChecksumErrors.Count > 0;
}
/// <summary>
/// Information about a pending migration.
/// </summary>
public sealed record PendingMigrationInfo(string Name, MigrationCategory Category);
/// <summary>
/// Implementation of migration status service.
/// </summary>
internal sealed class MigrationStatusService : IMigrationStatusService
{
private readonly string _connectionString;
private readonly string _schemaName;
private readonly string _moduleName;
private readonly Assembly _migrationsAssembly;
private readonly ILogger _logger;
public MigrationStatusService(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger)
{
_connectionString = connectionString;
_schemaName = schemaName;
_moduleName = moduleName;
_migrationsAssembly = migrationsAssembly;
_logger = logger;
}
public async Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
await using var connection = new Npgsql.NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Check if migrations table exists
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false);
var applied = new Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>(StringComparer.Ordinal);
if (tableExists)
{
applied = await GetAppliedMigrationsAsync(connection, cancellationToken).ConfigureAwait(false);
}
// Load all migrations from assembly
var allMigrations = LoadMigrationsFromAssembly();
// Find pending and validate checksums
var pending = new List<PendingMigrationInfo>();
var checksumErrors = new List<string>();
foreach (var migration in allMigrations)
{
if (applied.TryGetValue(migration.Name, out var appliedInfo))
{
if (!string.Equals(migration.Checksum, appliedInfo.Checksum, StringComparison.Ordinal))
{
checksumErrors.Add(
$"Checksum mismatch for '{migration.Name}': " +
$"expected '{migration.Checksum[..16]}...', found '{appliedInfo.Checksum[..16]}...'");
}
}
else
{
pending.Add(new PendingMigrationInfo(migration.Name, migration.Category));
}
}
var lastApplied = applied
.OrderByDescending(kvp => kvp.Value.AppliedAt)
.FirstOrDefault();
return new MigrationStatus
{
ModuleName = _moduleName,
SchemaName = _schemaName,
AppliedCount = applied.Count,
PendingStartupCount = pending.Count(p => p.Category.IsAutomatic()),
PendingReleaseCount = pending.Count(p => p.Category.RequiresManualExecution()),
LastAppliedMigration = lastApplied.Key,
LastAppliedAt = lastApplied.Key is not null ? lastApplied.Value.AppliedAt : null,
PendingMigrations = pending,
ChecksumErrors = checksumErrors
};
}
private async Task<bool> CheckMigrationsTableExistsAsync(
Npgsql.NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new Npgsql.NpgsqlCommand(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = @schema
AND table_name = 'schema_migrations'
)
""",
connection);
command.Parameters.AddWithValue("schema", _schemaName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private async Task<Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>> GetAppliedMigrationsAsync(
Npgsql.NpgsqlConnection connection,
CancellationToken cancellationToken)
{
var result = new Dictionary<string, (string, DateTimeOffset)>(StringComparer.Ordinal);
await using var command = new Npgsql.NpgsqlCommand(
$"SELECT migration_name, checksum, applied_at FROM {_schemaName}.schema_migrations",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
result[reader.GetString(0)] = (reader.GetString(1), reader.GetFieldValue<DateTimeOffset>(2));
}
return result;
}
private List<(string Name, MigrationCategory Category, string Checksum)> LoadMigrationsFromAssembly()
{
var migrations = new List<(string, MigrationCategory, string)>();
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.OrderBy(name => name);
foreach (var resourceName in resourceNames)
{
using var stream = _migrationsAssembly.GetManifestResourceStream(resourceName);
if (stream is null) continue;
using var reader = new StreamReader(stream);
var content = reader.ReadToEnd();
var fileName = ExtractFileName(resourceName);
var category = MigrationCategoryExtensions.GetCategory(fileName);
var checksum = ComputeChecksum(content);
migrations.Add((fileName, category, checksum));
}
return migrations;
}
private static string ExtractFileName(string resourceName)
{
var parts = resourceName.Split('.');
for (var i = parts.Length - 1; i >= 0; i--)
{
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
{
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
}
}
return resourceName;
}
private static string ComputeChecksum(string content)
{
var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n");
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
}

View File

@@ -0,0 +1,479 @@
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Hosted service that runs database migrations at application startup.
/// Uses advisory locks to prevent race conditions in multi-instance deployments.
/// </summary>
/// <remarks>
/// This service:
/// - Acquires an advisory lock to prevent concurrent migrations
/// - Validates checksums of already-applied migrations
/// - Blocks startup if pending release migrations exist
/// - Runs only Category A (startup) and seed migrations automatically
/// </remarks>
public abstract class StartupMigrationHost : IHostedService
{
private readonly string _connectionString;
private readonly string _schemaName;
private readonly string _moduleName;
private readonly Assembly _migrationsAssembly;
private readonly ILogger _logger;
private readonly IHostApplicationLifetime _lifetime;
private readonly StartupMigrationOptions _options;
/// <summary>
/// Creates a new startup migration host.
/// </summary>
protected StartupMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
_migrationsAssembly = migrationsAssembly ?? throw new ArgumentNullException(nameof(migrationsAssembly));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_options = options ?? new StartupMigrationOptions();
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Migration: Startup migrations disabled for {Module}.", _moduleName);
return;
}
var sw = Stopwatch.StartNew();
try
{
_logger.LogInformation("Migration: Starting migration check for {Module}...", _moduleName);
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Step 1: Acquire advisory lock
var lockKey = ComputeLockKey(_schemaName);
if (!await TryAcquireLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false))
{
_logger.LogError(
"Migration: Failed to acquire lock for {Module} within {Timeout}s. Another instance may be running migrations.",
_moduleName, _options.LockTimeoutSeconds);
if (_options.FailOnLockTimeout)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
$"Could not acquire migration lock for {_moduleName} within {_options.LockTimeoutSeconds} seconds.");
}
return;
}
_logger.LogDebug("Migration: Lock acquired for {Module}.", _moduleName);
try
{
// Step 2: Ensure schema and migrations table exist
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
// Step 3: Load and categorize migrations
var allMigrations = LoadMigrationsFromAssembly();
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
.ConfigureAwait(false);
// Step 4: Validate checksums
var checksumErrors = ValidateChecksums(allMigrations, appliedMigrations);
if (checksumErrors.Count > 0)
{
foreach (var error in checksumErrors)
{
_logger.LogError("Migration: {Error}", error);
}
if (_options.FailOnChecksumMismatch)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
$"Migration checksum validation failed for {_moduleName}. See logs for details.");
}
}
// Step 5: Check for pending release migrations
var pendingRelease = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(m => m.Category.RequiresManualExecution())
.ToList();
if (pendingRelease.Count > 0)
{
_logger.LogError(
"Migration: {Count} pending release migration(s) require manual execution for {Module}:",
pendingRelease.Count, _moduleName);
foreach (var migration in pendingRelease)
{
_logger.LogError(" - {Migration} (Category: {Category})", migration.Name, migration.Category);
}
_logger.LogError("Run: stellaops db migrate --module {Module} --category release", _moduleName);
if (_options.FailOnPendingReleaseMigrations)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
$"Pending release migrations block startup for {_moduleName}. Run CLI migration first.");
}
}
// Step 6: Execute pending startup migrations
var pendingStartup = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(m => m.Category.IsAutomatic())
.OrderBy(m => m.Name)
.ToList();
if (pendingStartup.Count == 0)
{
_logger.LogInformation("Migration: Database is up to date for {Module}.", _moduleName);
}
else
{
_logger.LogInformation(
"Migration: {Count} pending startup migration(s) for {Module}.",
pendingStartup.Count, _moduleName);
foreach (var migration in pendingStartup)
{
await ApplyMigrationAsync(connection, migration, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"Migration: Applied {Count} migration(s) for {Module} in {Elapsed}ms.",
pendingStartup.Count, _moduleName, sw.ElapsedMilliseconds);
}
}
finally
{
// Step 7: Release lock
await ReleaseLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Migration: Lock released for {Module}.", _moduleName);
}
}
catch (Exception ex) when (ex is not InvalidOperationException)
{
_logger.LogError(ex, "Migration: Failed for {Module}.", _moduleName);
if (_options.FailOnMigrationError)
{
_lifetime.StopApplication();
throw;
}
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task<bool> TryAcquireLockAsync(
NpgsqlConnection connection,
long lockKey,
CancellationToken cancellationToken)
{
var timeout = TimeSpan.FromSeconds(_options.LockTimeoutSeconds);
var deadline = DateTime.UtcNow.Add(timeout);
var retryDelay = TimeSpan.FromMilliseconds(500);
while (DateTime.UtcNow < deadline)
{
await using var command = new NpgsqlCommand(
"SELECT pg_try_advisory_lock(@key)",
connection);
command.Parameters.AddWithValue("key", lockKey);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
if (result is true)
{
return true;
}
_logger.LogDebug(
"Migration: Lock for {Module} is held by another instance, retrying in {Delay}ms...",
_moduleName, retryDelay.TotalMilliseconds);
await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false);
// Exponential backoff up to 5 seconds
retryDelay = TimeSpan.FromMilliseconds(Math.Min(retryDelay.TotalMilliseconds * 1.5, 5000));
}
return false;
}
private async Task ReleaseLockAsync(NpgsqlConnection connection, long lockKey, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)",
connection);
command.Parameters.AddWithValue("key", lockKey);
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"CREATE SCHEMA IF NOT EXISTS {_schemaName}",
connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureMigrationsTableAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"""
CREATE TABLE IF NOT EXISTS {_schemaName}.schema_migrations (
migration_name TEXT PRIMARY KEY,
category TEXT NOT NULL DEFAULT 'startup',
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_by TEXT,
duration_ms INT,
CONSTRAINT valid_category CHECK (category IN ('startup', 'release', 'seed', 'data'))
);
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at
ON {_schemaName}.schema_migrations(applied_at DESC);
""",
connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<Dictionary<string, AppliedMigration>> GetAppliedMigrationsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
var result = new Dictionary<string, AppliedMigration>(StringComparer.Ordinal);
await using var command = new NpgsqlCommand(
$"SELECT migration_name, category, checksum, applied_at FROM {_schemaName}.schema_migrations",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var name = reader.GetString(0);
result[name] = new AppliedMigration(
Name: name,
Category: reader.GetString(1),
Checksum: reader.GetString(2),
AppliedAt: reader.GetFieldValue<DateTimeOffset>(3));
}
return result;
}
private List<PendingMigration> LoadMigrationsFromAssembly()
{
var migrations = new List<PendingMigration>();
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.OrderBy(name => name);
foreach (var resourceName in resourceNames)
{
using var stream = _migrationsAssembly.GetManifestResourceStream(resourceName);
if (stream is null) continue;
using var reader = new StreamReader(stream);
var content = reader.ReadToEnd();
var fileName = ExtractFileName(resourceName);
var category = MigrationCategoryExtensions.GetCategory(fileName);
var checksum = ComputeChecksum(content);
migrations.Add(new PendingMigration(
Name: fileName,
ResourceName: resourceName,
Category: category,
Checksum: checksum,
Content: content));
}
return migrations;
}
private List<string> ValidateChecksums(
List<PendingMigration> allMigrations,
Dictionary<string, AppliedMigration> appliedMigrations)
{
var errors = new List<string>();
foreach (var migration in allMigrations)
{
if (appliedMigrations.TryGetValue(migration.Name, out var applied))
{
if (!string.Equals(migration.Checksum, applied.Checksum, StringComparison.Ordinal))
{
errors.Add(
$"Checksum mismatch for '{migration.Name}': " +
$"expected '{migration.Checksum[..16]}...', found '{applied.Checksum[..16]}...'");
}
}
}
return errors;
}
private async Task ApplyMigrationAsync(
NpgsqlConnection connection,
PendingMigration migration,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Migration: Applying {Migration} ({Category})...",
migration.Name, migration.Category);
var sw = Stopwatch.StartNew();
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
try
{
// Execute migration SQL
await using (var migrationCommand = new NpgsqlCommand(migration.Content, connection, transaction))
{
migrationCommand.CommandTimeout = _options.MigrationTimeoutSeconds;
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Record migration
await using (var recordCommand = new NpgsqlCommand(
$"""
INSERT INTO {_schemaName}.schema_migrations
(migration_name, category, checksum, duration_ms, applied_by)
VALUES (@name, @category, @checksum, @duration, @applied_by)
ON CONFLICT (migration_name) DO NOTHING
""",
connection,
transaction))
{
recordCommand.Parameters.AddWithValue("name", migration.Name);
recordCommand.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant());
recordCommand.Parameters.AddWithValue("checksum", migration.Checksum);
recordCommand.Parameters.AddWithValue("duration", (int)sw.ElapsedMilliseconds);
recordCommand.Parameters.AddWithValue("applied_by", Environment.MachineName);
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Migration: {Migration} completed in {Duration}ms.",
migration.Name, sw.ElapsedMilliseconds);
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
private static long ComputeLockKey(string schemaName)
{
// Use a deterministic hash of the schema name as the lock key
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(schemaName));
return BitConverter.ToInt64(hash, 0);
}
private static string ComputeChecksum(string content)
{
// Normalize line endings for consistent checksums across platforms
var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n");
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static string ExtractFileName(string resourceName)
{
var lastSlash = resourceName.LastIndexOf('/');
var lastDot = resourceName.LastIndexOf('.');
// Handle namespace-style resource names (e.g., "Namespace.Migrations.001_schema.sql")
if (lastSlash < 0)
{
// Find the filename by looking for pattern like "001_" or "S001_"
var parts = resourceName.Split('.');
for (var i = parts.Length - 1; i >= 0; i--)
{
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
{
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
}
}
}
return lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
}
private record AppliedMigration(string Name, string Category, string Checksum, DateTimeOffset AppliedAt);
private record PendingMigration(string Name, string ResourceName, MigrationCategory Category, string Checksum, string Content);
}
/// <summary>
/// Options for startup migration behavior.
/// </summary>
public sealed class StartupMigrationOptions
{
/// <summary>
/// Whether to run migrations at startup. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Timeout in seconds for acquiring the advisory lock. Default: 120.
/// </summary>
public int LockTimeoutSeconds { get; set; } = 120;
/// <summary>
/// Timeout in seconds for individual migration execution. Default: 300.
/// </summary>
public int MigrationTimeoutSeconds { get; set; } = 300;
/// <summary>
/// Whether to fail startup if lock cannot be acquired. Default: true.
/// </summary>
public bool FailOnLockTimeout { get; set; } = true;
/// <summary>
/// Whether to fail startup if checksum validation fails. Default: true.
/// </summary>
public bool FailOnChecksumMismatch { get; set; } = true;
/// <summary>
/// Whether to fail startup if there are pending release migrations. Default: true.
/// </summary>
public bool FailOnPendingReleaseMigrations { get; set; } = true;
/// <summary>
/// Whether to fail startup if migration execution fails. Default: true.
/// </summary>
public bool FailOnMigrationError { get; set; } = true;
}

View File

@@ -0,0 +1,205 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public class MigrationCategoryTests
{
#region GetCategory Tests - Startup Migrations (001-099)
[Theory]
[InlineData("001_initial_schema.sql", MigrationCategory.Startup)]
[InlineData("001_initial_schema", MigrationCategory.Startup)]
[InlineData("01_short_prefix.sql", MigrationCategory.Startup)]
[InlineData("1_single_digit.sql", MigrationCategory.Startup)]
[InlineData("050_middle_range.sql", MigrationCategory.Startup)]
[InlineData("099_last_startup.sql", MigrationCategory.Startup)]
public void GetCategory_StartupMigrations_ReturnsStartup(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Release Migrations (100-199, 200+)
[Theory]
[InlineData("100_drop_legacy_columns.sql", MigrationCategory.Release)]
[InlineData("150_rename_table.sql", MigrationCategory.Release)]
[InlineData("199_last_release.sql", MigrationCategory.Release)]
[InlineData("200_major_version.sql", MigrationCategory.Release)]
[InlineData("250_another_major.sql", MigrationCategory.Release)]
[InlineData("999_very_high_number.sql", MigrationCategory.Release)]
public void GetCategory_ReleaseMigrations_ReturnsRelease(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Seed Migrations (S001-S999)
[Theory]
[InlineData("S001_default_roles.sql", MigrationCategory.Seed)]
[InlineData("S100_builtin_policies.sql", MigrationCategory.Seed)]
[InlineData("S999_last_seed.sql", MigrationCategory.Seed)]
[InlineData("s001_lowercase.sql", MigrationCategory.Seed)]
public void GetCategory_SeedMigrations_ReturnsSeed(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("Schema_setup.sql")] // S followed by non-digit
[InlineData("Setup_tables.sql")]
public void GetCategory_SPrefix_NotFollowedByDigit_ReturnsStartup(string migrationName)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(MigrationCategory.Startup);
}
#endregion
#region GetCategory Tests - Data Migrations (DM001-DM999)
[Theory]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM100_MigrateUserPrefs.sql", MigrationCategory.Data)]
[InlineData("DM999_FinalDataMigration.sql", MigrationCategory.Data)]
[InlineData("dm001_lowercase.sql", MigrationCategory.Data)]
public void GetCategory_DataMigrations_ReturnsData(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Edge Cases
[Fact]
public void GetCategory_NullMigrationName_ThrowsArgumentNullException()
{
var act = () => MigrationCategoryExtensions.GetCategory(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void GetCategory_EmptyMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(string.Empty);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void GetCategory_WhitespaceMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(" ");
act.Should().Throw<ArgumentException>();
}
[Theory]
[InlineData("no_prefix_migration.sql", MigrationCategory.Startup)]
[InlineData("migration.sql", MigrationCategory.Startup)]
[InlineData("abc_123.sql", MigrationCategory.Startup)]
public void GetCategory_UnknownPattern_DefaultsToStartup(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("migrations/subfolder/001_test.sql", MigrationCategory.Startup)]
[InlineData("100_release.SQL", MigrationCategory.Release)] // Different extension case
[InlineData("001_test", MigrationCategory.Startup)] // No extension
public void GetCategory_PathVariations_ExtractsCorrectly(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Fact]
public void GetCategory_ZeroPrefix_ReturnsStartup()
{
// 0 should default to Startup as per the switch expression
var result = MigrationCategoryExtensions.GetCategory("0_zero_prefix.sql");
result.Should().Be(MigrationCategory.Startup);
}
#endregion
#region IsAutomatic Tests
[Theory]
[InlineData(MigrationCategory.Startup, true)]
[InlineData(MigrationCategory.Seed, true)]
[InlineData(MigrationCategory.Release, false)]
[InlineData(MigrationCategory.Data, false)]
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.IsAutomatic();
result.Should().Be(expected);
}
#endregion
#region RequiresManualExecution Tests
[Theory]
[InlineData(MigrationCategory.Startup, false)]
[InlineData(MigrationCategory.Seed, false)]
[InlineData(MigrationCategory.Release, true)]
[InlineData(MigrationCategory.Data, true)]
public void RequiresManualExecution_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.RequiresManualExecution();
result.Should().Be(expected);
}
#endregion
#region IsAutomatic and RequiresManualExecution are Mutually Exclusive
[Theory]
[InlineData(MigrationCategory.Startup)]
[InlineData(MigrationCategory.Release)]
[InlineData(MigrationCategory.Seed)]
[InlineData(MigrationCategory.Data)]
public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(MigrationCategory category)
{
var isAutomatic = category.IsAutomatic();
var requiresManual = category.RequiresManualExecution();
// They should be opposite of each other
(isAutomatic ^ requiresManual).Should().BeTrue(
$"Category {category} should be either automatic OR manual, not both or neither");
}
#endregion
#region Real-World Migration Name Tests
[Theory]
[InlineData("001_create_auth_schema.sql", MigrationCategory.Startup)]
[InlineData("002_create_tenants_table.sql", MigrationCategory.Startup)]
[InlineData("003_create_users_table.sql", MigrationCategory.Startup)]
[InlineData("004_add_audit_columns.sql", MigrationCategory.Startup)]
[InlineData("100_drop_legacy_auth_columns.sql", MigrationCategory.Release)]
[InlineData("101_migrate_user_roles.sql", MigrationCategory.Release)]
[InlineData("S001_default_admin_role.sql", MigrationCategory.Seed)]
[InlineData("S002_system_permissions.sql", MigrationCategory.Seed)]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM002_MigratePasswordHashes.sql", MigrationCategory.Data)]
public void GetCategory_RealWorldMigrationNames_CategorizesCorrectly(
string migrationName,
MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
}

View File

@@ -0,0 +1,548 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using Testcontainers.PostgreSql;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
/// <summary>
/// Integration tests for StartupMigrationHost.
/// Uses Testcontainers to spin up a real PostgreSQL instance.
/// </summary>
public sealed class StartupMigrationHostTests : IAsyncLifetime
{
private PostgreSqlContainer? _container;
private string ConnectionString => _container?.GetConnectionString()
?? throw new InvalidOperationException("Container not initialized");
public async Task InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await _container.StartAsync();
}
public async Task DisposeAsync()
{
if (_container != null)
{
await _container.DisposeAsync();
}
}
#region Migration Execution Tests
[Fact]
public async Task StartAsync_WithPendingStartupMigrations_AppliesThem()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
// FailOnPendingReleaseMigrations = false because test assembly includes release migrations
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - Check that migrations table has records
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
appliedCount.Should().BeGreaterThan(0);
// Verify specific startup/seed migrations were applied (not release)
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().Contain("002_add_column.sql");
migrations.Should().Contain("S001_seed_data.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not auto-applied
}
[Fact]
public async Task StartAsync_WithAlreadyAppliedMigrations_SkipsThem()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First run - apply migrations
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
var initialCount = await GetAppliedMigrationCountAsync(schemaName);
// Second run - should skip already applied
var host2 = CreateTestHost(schemaName, options: options);
await host2.StartAsync(CancellationToken.None);
// Assert - count should remain the same
var finalCount = await GetAppliedMigrationCountAsync(schemaName);
finalCount.Should().Be(initialCount);
}
[Fact]
public async Task StartAsync_CreatesSchemaAndMigrationsTable()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - schema should exist
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeTrue();
// Assert - migrations table should exist
var tableExists = await TableExistsAsync(schemaName, "schema_migrations");
tableExists.Should().BeTrue();
}
[Fact]
public async Task StartAsync_WithDisabled_SkipsMigrations()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { Enabled = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
#endregion
#region Release Migration Tests
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_ThrowsAndStopsApplication()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var lifetimeMock = new Mock<IHostApplicationLifetime>();
// Default FailOnPendingReleaseMigrations = true, which should cause failure
// because the test assembly includes 100_release_migration.sql
var host = CreateTestHost(schemaName, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_WhenFailOnPendingFalse_DoesNotThrow()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw
await host.StartAsync(CancellationToken.None);
// Assert - startup migrations should still be applied
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not applied automatically
}
#endregion
#region Checksum Validation Tests
[Fact]
public async Task StartAsync_WithChecksumMismatch_ThrowsAndStopsApplication()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum in the database
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try to run again with checksum validation (and still ignore release migrations)
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var options2 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host2 = CreateTestHost(schemaName, options: options2, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host2.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithChecksumMismatch_WhenFailOnMismatchFalse_DoesNotThrow()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options1 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options1);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try with checksum mismatch allowed
var options2 = new StartupMigrationOptions
{
FailOnChecksumMismatch = false,
FailOnPendingReleaseMigrations = false
};
var host2 = CreateTestHost(schemaName, options: options2);
// Act - should not throw
await host2.StartAsync(CancellationToken.None);
}
#endregion
#region Advisory Lock Tests
[Fact]
public async Task StartAsync_MultipleConcurrentInstances_OnlyOneRunsMigrations()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var runCount = 0;
var lockObject = new object();
// Create 5 concurrent hosts
var tasks = Enumerable.Range(0, 5)
.Select(_ =>
{
var host = CreateTestHost(
schemaName,
options: new StartupMigrationOptions
{
LockTimeoutSeconds = 30,
FailOnPendingReleaseMigrations = false
});
return host.StartAsync(CancellationToken.None)
.ContinueWith(_ =>
{
lock (lockObject) { runCount++; }
});
})
.ToArray();
// Act
await Task.WhenAll(tasks);
// Assert - all should complete, but migrations applied only once
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
// Each migration should only appear once in the table
var counts = await GetMigrationAppliedCountsAsync(schemaName);
foreach (var count in counts.Values)
{
count.Should().Be(1);
}
}
[Fact]
public async Task StartAsync_LockTimeout_ThrowsWhenFailOnLockTimeoutTrue()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = true
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var host = CreateTestHost(schemaName, options: options, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
[Fact]
public async Task StartAsync_LockTimeout_DoesNotThrowWhenFailOnLockTimeoutFalse()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = false
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw, just skip migrations
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created since lock wasn't acquired
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
#endregion
#region Migration Recording Tests
[Fact]
public async Task StartAsync_RecordsMigrationMetadata()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - check metadata is recorded
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category, checksum, duration_ms, applied_by FROM {schemaName}.schema_migrations WHERE migration_name = '001_create_test_table.sql'",
conn);
await using var reader = await cmd.ExecuteReaderAsync();
reader.Read().Should().BeTrue();
reader.GetString(0).Should().Be("startup"); // category
reader.GetString(1).Should().NotBeNullOrEmpty(); // checksum
reader.GetInt32(2).Should().BeGreaterOrEqualTo(0); // duration_ms
reader.GetString(3).Should().NotBeNullOrEmpty(); // applied_by
}
[Fact]
public async Task StartAsync_SeedMigrations_RecordedAsSeedCategory()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category FROM {schemaName}.schema_migrations WHERE migration_name = 'S001_seed_data.sql'",
conn);
var category = await cmd.ExecuteScalarAsync();
category.Should().Be("seed");
}
#endregion
#region Helper Methods
private TestMigrationHost CreateTestHost(
string schemaName,
StartupMigrationOptions? options = null,
IHostApplicationLifetime? lifetime = null)
{
return new TestMigrationHost(
connectionString: ConnectionString,
schemaName: schemaName,
moduleName: "Test",
migrationsAssembly: typeof(StartupMigrationHostTests).Assembly,
logger: NullLogger<TestMigrationHost>.Instance,
lifetime: lifetime ?? CreateMockLifetime(),
options: options);
}
private static IHostApplicationLifetime CreateMockLifetime()
{
var mock = new Mock<IHostApplicationLifetime>();
return mock.Object;
}
private async Task<int> GetAppliedMigrationCountAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT COUNT(*) FROM {schemaName}.schema_migrations",
conn);
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
private async Task<List<string>> GetAppliedMigrationNamesAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name FROM {schemaName}.schema_migrations ORDER BY migration_name",
conn);
var names = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
names.Add(reader.GetString(0));
}
return names;
}
private async Task<Dictionary<string, int>> GetMigrationAppliedCountsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name, COUNT(*) FROM {schemaName}.schema_migrations GROUP BY migration_name",
conn);
var counts = new Dictionary<string, int>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
counts[reader.GetString(0)] = Convert.ToInt32(reader.GetInt64(1));
}
return counts;
}
private async Task<bool> SchemaExistsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
conn);
cmd.Parameters.AddWithValue("name", schemaName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task<bool> TableExistsAsync(string schemaName, string tableName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = @schema AND table_name = @table)",
conn);
cmd.Parameters.AddWithValue("schema", schemaName);
cmd.Parameters.AddWithValue("table", tableName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task CorruptChecksumAsync(string schemaName, string migrationName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"UPDATE {schemaName}.schema_migrations SET checksum = 'corrupted_checksum' WHERE migration_name = @name",
conn);
cmd.Parameters.AddWithValue("name", migrationName);
await cmd.ExecuteNonQueryAsync();
}
private static long ComputeLockKey(string schemaName)
{
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(schemaName));
return BitConverter.ToInt64(hash, 0);
}
#endregion
}
/// <summary>
/// Concrete test implementation of StartupMigrationHost.
/// Uses embedded resources from the test assembly.
/// </summary>
internal sealed class TestMigrationHost : StartupMigrationHost
{
public TestMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}

View File

@@ -0,0 +1,9 @@
-- Migration: 001_create_test_table
-- Category: startup
-- Description: Create initial test table
CREATE TABLE IF NOT EXISTS test_table (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,5 @@
-- Migration: 002_add_column
-- Category: startup
-- Description: Add description column to test table
ALTER TABLE test_table ADD COLUMN IF NOT EXISTS description TEXT;

View File

@@ -0,0 +1,5 @@
-- Migration: 100_release_migration
-- Category: release
-- Description: A release migration that requires manual execution
ALTER TABLE test_table DROP COLUMN IF EXISTS deprecated_column;

View File

@@ -0,0 +1,7 @@
-- Migration: S001_seed_data
-- Category: seed
-- Description: Insert seed data
INSERT INTO test_table (name, description)
VALUES ('seed1', 'First seed record')
ON CONFLICT DO NOTHING;