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:
StellaOps Bot
2025-12-06 20:04:03 +02:00
parent a6f1406509
commit 05597616d6
178 changed files with 12022 additions and 4545 deletions

View File

@@ -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);
}

View File

@@ -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;