feat: Add Go module and workspace test fixtures
- Created expected JSON files for Go modules and workspaces. - Added go.mod and go.sum files for example projects. - Implemented private module structure with expected JSON output. - Introduced vendored dependencies with corresponding expected JSON. - Developed PostgresGraphJobStore for managing graph jobs. - Established SQL migration scripts for graph jobs schema. - Implemented GraphJobRepository for CRUD operations on graph jobs. - Created IGraphJobRepository interface for repository abstraction. - Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
@@ -1,46 +1,51 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Runs SQL migrations for a PostgreSQL schema.
|
||||
/// Migrations are idempotent and tracked in a schema_migrations table.
|
||||
/// Runs PostgreSQL migrations from filesystem or embedded resources with advisory-lock coordination.
|
||||
/// </summary>
|
||||
public sealed class MigrationRunner
|
||||
public sealed class MigrationRunner : IMigrationRunner
|
||||
{
|
||||
private const int DefaultLockTimeoutSeconds = 120;
|
||||
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _moduleName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new migration runner.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">Schema name for the module.</param>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public MigrationRunner(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
ILogger logger)
|
||||
/// <inheritdoc />
|
||||
public string SchemaName { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ModuleName { get; }
|
||||
|
||||
public MigrationRunner(string connectionString, string schemaName, string moduleName, ILogger logger)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
|
||||
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
|
||||
SchemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
|
||||
ModuleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations from the specified path.
|
||||
/// Backward-compatible overload that preserves the previous signature (string, CancellationToken).
|
||||
/// </summary>
|
||||
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of migrations applied.</returns>
|
||||
public async Task<int> RunAsync(string migrationsPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await RunAsync(migrationsPath, options: null, cancellationToken).ConfigureAwait(false);
|
||||
return result.AppliedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MigrationResult> RunAsync(
|
||||
string migrationsPath,
|
||||
MigrationRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(migrationsPath);
|
||||
|
||||
@@ -49,186 +54,85 @@ public sealed class MigrationRunner
|
||||
throw new DirectoryNotFoundException($"Migrations directory not found: {migrationsPath}");
|
||||
}
|
||||
|
||||
var migrationFiles = Directory.GetFiles(migrationsPath, "*.sql")
|
||||
.OrderBy(f => Path.GetFileName(f))
|
||||
var migrations = Directory.GetFiles(migrationsPath, "*.sql")
|
||||
.OrderBy(Path.GetFileName)
|
||||
.Select(async path =>
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var fileName = Path.GetFileName(path);
|
||||
return new PendingMigration(
|
||||
Name: fileName,
|
||||
Category: MigrationCategoryExtensions.GetCategory(fileName),
|
||||
Checksum: ComputeChecksum(content),
|
||||
Content: content);
|
||||
})
|
||||
.Select(t => t.GetAwaiter().GetResult())
|
||||
.ToList();
|
||||
|
||||
if (migrationFiles.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No migration files found in {Path} for module {Module}.",
|
||||
migrationsPath, _moduleName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure schema exists
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure migrations table exists
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get applied migrations
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedCount = 0;
|
||||
|
||||
foreach (var file in migrationFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
|
||||
if (appliedMigrations.Contains(fileName))
|
||||
{
|
||||
_logger.LogDebug("Migration {Migration} already applied for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying migration {Migration} for module {Module}...",
|
||||
fileName, _moduleName);
|
||||
|
||||
await ApplyMigrationAsync(connection, file, fileName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
appliedCount++;
|
||||
|
||||
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
}
|
||||
|
||||
if (appliedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Applied {Count} migration(s) for module {Module}.",
|
||||
appliedCount, _moduleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Database is up to date for module {Module}.", _moduleName);
|
||||
}
|
||||
|
||||
return appliedCount;
|
||||
return ExecuteMigrationsAsync(migrations, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations from embedded resources in an assembly.
|
||||
/// Backward-compatible overload that preserves the previous signature (Assembly, string?, CancellationToken).
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of migrations applied.</returns>
|
||||
public async Task<int> RunFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await RunFromAssemblyAsync(assembly, resourcePrefix, options: null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return result.AppliedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MigrationResult> RunFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
MigrationRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(name => string.IsNullOrEmpty(resourcePrefix) || name.StartsWith(resourcePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name)
|
||||
.ToList();
|
||||
|
||||
if (resourceNames.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No embedded migration resources found in assembly {Assembly} for module {Module}.",
|
||||
assembly.GetName().Name, _moduleName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure schema exists
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure migrations table exists
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get applied migrations
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedCount = 0;
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
// Extract just the filename from the resource name
|
||||
var fileName = ExtractMigrationFileName(resourceName);
|
||||
|
||||
if (appliedMigrations.Contains(fileName))
|
||||
{
|
||||
_logger.LogDebug("Migration {Migration} already applied for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying migration {Migration} for module {Module}...",
|
||||
fileName, _moduleName);
|
||||
|
||||
await ApplyMigrationFromResourceAsync(connection, assembly, resourceName, fileName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
appliedCount++;
|
||||
|
||||
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
}
|
||||
|
||||
if (appliedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Applied {Count} embedded migration(s) for module {Module}.",
|
||||
appliedCount, _moduleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Database is up to date for module {Module}.", _moduleName);
|
||||
}
|
||||
|
||||
return appliedCount;
|
||||
var migrations = LoadMigrationsFromAssembly(assembly, resourcePrefix);
|
||||
return ExecuteMigrationsAsync(migrations, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current migration version (latest applied migration).
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!tableExists) return null;
|
||||
if (!await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"SELECT migration_name FROM {_schemaName}.schema_migrations ORDER BY applied_at DESC LIMIT 1",
|
||||
$"SELECT migration_name FROM {SchemaName}.schema_migrations ORDER BY applied_at DESC LIMIT 1",
|
||||
connection);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result as string;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all applied migrations.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!tableExists) return [];
|
||||
if (!await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Array.Empty<MigrationInfo>();
|
||||
}
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"""
|
||||
SELECT migration_name, applied_at, checksum
|
||||
FROM {_schemaName}.schema_migrations
|
||||
FROM {SchemaName}.schema_migrations
|
||||
ORDER BY applied_at
|
||||
""",
|
||||
connection);
|
||||
@@ -247,10 +151,175 @@ public sealed class MigrationRunner
|
||||
return migrations;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> ValidateChecksumsAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var applied = await GetAppliedMigrationsAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
var allMigrations = LoadMigrationsFromAssembly(assembly, resourcePrefix);
|
||||
|
||||
return ValidateChecksums(allMigrations, applied);
|
||||
}
|
||||
|
||||
private async Task<MigrationResult> ExecuteMigrationsAsync(
|
||||
IReadOnlyList<PendingMigration> allMigrations,
|
||||
MigrationRunOptions? runOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = runOptions ?? new MigrationRunOptions();
|
||||
var started = Stopwatch.StartNew();
|
||||
var appliedDetails = new List<AppliedMigrationDetail>();
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Coordination: advisory lock to avoid concurrent runners
|
||||
var lockKey = ComputeLockKey(SchemaName);
|
||||
var lockAcquired = await TryAcquireLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!lockAcquired)
|
||||
{
|
||||
return MigrationResult.Failed(
|
||||
$"Could not acquire migration lock for schema '{SchemaName}' within {DefaultLockTimeoutSeconds} seconds.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var applied = await GetAppliedMigrationsAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
var checksumErrors = options.ValidateChecksums
|
||||
? ValidateChecksums(allMigrations, applied)
|
||||
: new List<string>();
|
||||
|
||||
if (checksumErrors.Count > 0 && options.FailOnChecksumMismatch)
|
||||
{
|
||||
return MigrationResult.Failed(
|
||||
$"Checksum validation failed for {ModuleName}.",
|
||||
checksumErrors);
|
||||
}
|
||||
|
||||
var pending = allMigrations.Where(m => !applied.ContainsKey(m.Name)).ToList();
|
||||
var filteredOut = options.CategoryFilter.HasValue
|
||||
? pending.Where(m => m.Category != options.CategoryFilter.Value).ToList()
|
||||
: new List<PendingMigration>();
|
||||
|
||||
var toApply = options.CategoryFilter.HasValue
|
||||
? pending.Where(m => m.Category == options.CategoryFilter.Value).OrderBy(m => m.Name).ToList()
|
||||
: pending.OrderBy(m => m.Name).ToList();
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
appliedDetails.AddRange(toApply.Select(m => new AppliedMigrationDetail(
|
||||
Name: m.Name,
|
||||
Category: m.Category,
|
||||
DurationMs: 0,
|
||||
WasDryRun: true)));
|
||||
|
||||
return MigrationResult.Successful(
|
||||
appliedCount: 0,
|
||||
skippedCount: applied.Count,
|
||||
filteredCount: filteredOut.Count,
|
||||
durationMs: started.ElapsedMilliseconds,
|
||||
appliedMigrations: appliedDetails);
|
||||
}
|
||||
|
||||
foreach (var migration in toApply)
|
||||
{
|
||||
var duration = await ApplyMigrationAsync(connection, migration, options.TimeoutSeconds, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
appliedDetails.Add(new AppliedMigrationDetail(
|
||||
Name: migration.Name,
|
||||
Category: migration.Category,
|
||||
DurationMs: duration,
|
||||
WasDryRun: false));
|
||||
}
|
||||
|
||||
return MigrationResult.Successful(
|
||||
appliedCount: toApply.Count,
|
||||
skippedCount: applied.Count,
|
||||
filteredCount: filteredOut.Count,
|
||||
durationMs: started.ElapsedMilliseconds,
|
||||
appliedMigrations: appliedDetails);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to run migrations for {Module}.", ModuleName);
|
||||
return MigrationResult.Failed(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ReleaseLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> ApplyMigrationAsync(
|
||||
NpgsqlConnection connection,
|
||||
PendingMigration migration,
|
||||
int timeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Applying migration {Migration} ({Category}) for {Module}...", migration.Name, migration.Category, ModuleName);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await using (var command = new NpgsqlCommand(migration.Content, connection, transaction))
|
||||
{
|
||||
command.CommandTimeout = timeoutSeconds;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var record = 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))
|
||||
{
|
||||
record.Parameters.AddWithValue("name", migration.Name);
|
||||
record.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant());
|
||||
record.Parameters.AddWithValue("checksum", migration.Checksum);
|
||||
record.Parameters.AddWithValue("duration", (int)sw.ElapsedMilliseconds);
|
||||
record.Parameters.AddWithValue("applied_by", Environment.MachineName);
|
||||
await record.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Applied migration {Migration} for {Module} in {Duration}ms.", migration.Name, ModuleName, sw.ElapsedMilliseconds);
|
||||
return sw.ElapsedMilliseconds;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"CREATE SCHEMA IF NOT EXISTS {_schemaName};", connection);
|
||||
$"CREATE SCHEMA IF NOT EXISTS {SchemaName};",
|
||||
connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -258,13 +327,30 @@ public sealed class MigrationRunner
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"""
|
||||
CREATE TABLE IF NOT EXISTS {_schemaName}.schema_migrations (
|
||||
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(),
|
||||
checksum TEXT NOT NULL
|
||||
applied_by TEXT,
|
||||
duration_ms INT,
|
||||
CONSTRAINT valid_category CHECK (category IN ('startup','release','seed','data'))
|
||||
);
|
||||
|
||||
ALTER TABLE {SchemaName}.schema_migrations
|
||||
ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'startup';
|
||||
ALTER TABLE {SchemaName}.schema_migrations
|
||||
ADD COLUMN IF NOT EXISTS checksum TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE {SchemaName}.schema_migrations
|
||||
ADD COLUMN IF NOT EXISTS applied_by TEXT;
|
||||
ALTER TABLE {SchemaName}.schema_migrations
|
||||
ADD COLUMN IF NOT EXISTS duration_ms INT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at
|
||||
ON {SchemaName}.schema_migrations(applied_at DESC);
|
||||
""",
|
||||
connection);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -276,146 +362,169 @@ public sealed class MigrationRunner
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = @schema
|
||||
AND table_name = 'schema_migrations'
|
||||
WHERE table_schema = @schema AND table_name = 'schema_migrations'
|
||||
);
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("schema", _schemaName);
|
||||
|
||||
command.Parameters.AddWithValue("schema", SchemaName);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetAppliedMigrationsAsync(
|
||||
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 FROM {_schemaName}.schema_migrations;",
|
||||
$"SELECT migration_name, category, checksum, applied_at FROM {SchemaName}.schema_migrations",
|
||||
connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var migrations = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
migrations.Add(reader.GetString(0));
|
||||
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 static List<string> ValidateChecksums(
|
||||
IReadOnlyList<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 '{Preview(migration.Checksum)}...', found '{Preview(applied.Checksum)}...'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static string Preview(string checksum) =>
|
||||
checksum.Length > 16 ? checksum[..16] : checksum;
|
||||
|
||||
private static List<PendingMigration> LoadMigrationsFromAssembly(Assembly assembly, string? resourcePrefix)
|
||||
{
|
||||
var resources = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(name => string.IsNullOrWhiteSpace(resourcePrefix) ||
|
||||
name.StartsWith(resourcePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name);
|
||||
|
||||
var migrations = new List<PendingMigration>();
|
||||
foreach (var resourceName in resources)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) continue;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = reader.ReadToEnd();
|
||||
var fileName = ExtractFileName(resourceName);
|
||||
|
||||
migrations.Add(new PendingMigration(
|
||||
Name: fileName,
|
||||
Category: MigrationCategoryExtensions.GetCategory(fileName),
|
||||
Checksum: ComputeChecksum(content),
|
||||
Content: content));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
NpgsqlConnection connection,
|
||||
string filePath,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
private static string ExtractFileName(string resourceName)
|
||||
{
|
||||
var sql = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var checksum = ComputeChecksum(sql);
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Run migration SQL
|
||||
await using (var migrationCommand = new NpgsqlCommand(sql, connection, transaction))
|
||||
{
|
||||
migrationCommand.CommandTimeout = 300; // 5 minute timeout for migrations
|
||||
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
await using (var recordCommand = new NpgsqlCommand(
|
||||
$"""
|
||||
INSERT INTO {_schemaName}.schema_migrations (migration_name, checksum)
|
||||
VALUES (@name, @checksum);
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
recordCommand.Parameters.AddWithValue("name", fileName);
|
||||
recordCommand.Parameters.AddWithValue("checksum", checksum);
|
||||
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationFromResourceAsync(
|
||||
NpgsqlConnection connection,
|
||||
Assembly assembly,
|
||||
string resourceName,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Could not load embedded resource: {resourceName}");
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
var checksum = ComputeChecksum(sql);
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Run migration SQL
|
||||
await using (var migrationCommand = new NpgsqlCommand(sql, connection, transaction))
|
||||
{
|
||||
migrationCommand.CommandTimeout = 300; // 5 minute timeout for migrations
|
||||
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
await using (var recordCommand = new NpgsqlCommand(
|
||||
$"""
|
||||
INSERT INTO {_schemaName}.schema_migrations (migration_name, checksum)
|
||||
VALUES (@name, @checksum);
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
recordCommand.Parameters.AddWithValue("name", fileName);
|
||||
recordCommand.Parameters.AddWithValue("checksum", checksum);
|
||||
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractMigrationFileName(string resourceName)
|
||||
{
|
||||
// Resource names use the LogicalName from .csproj which is just the filename
|
||||
// e.g., "001_initial.sql" or might have path prefix like "Migrations/001_initial.sql"
|
||||
var lastSlash = resourceName.LastIndexOf('/');
|
||||
return lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
|
||||
if (lastSlash >= 0)
|
||||
{
|
||||
return resourceName[(lastSlash + 1)..];
|
||||
}
|
||||
|
||||
// Namespace-style resources: "...Migrations.001_initial.sql"
|
||||
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 bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
// Normalize line endings for consistent checksums
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an applied migration.
|
||||
/// </summary>
|
||||
public readonly record struct MigrationInfo(string Name, DateTimeOffset AppliedAt, string Checksum);
|
||||
private static long ComputeLockKey(string schemaName)
|
||||
{
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(schemaName));
|
||||
return BitConverter.ToInt64(hash, 0);
|
||||
}
|
||||
|
||||
private static async Task<bool> TryAcquireLockAsync(
|
||||
NpgsqlConnection connection,
|
||||
long lockKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(DefaultLockTimeoutSeconds);
|
||||
var delay = 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;
|
||||
}
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * 1.5, 5000));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static 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 record AppliedMigration(string Name, string Category, string Checksum, DateTimeOffset AppliedAt);
|
||||
private record PendingMigration(string Name, MigrationCategory Category, string Checksum, string Content);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ public sealed record PendingMigrationInfo(string Name, MigrationCategory Categor
|
||||
/// <summary>
|
||||
/// Implementation of migration status service.
|
||||
/// </summary>
|
||||
internal sealed class MigrationStatusService : IMigrationStatusService
|
||||
public sealed class MigrationStatusService : IMigrationStatusService
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
|
||||
Reference in New Issue
Block a user