-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDbMigrationHostedService.cs
More file actions
153 lines (133 loc) · 5.73 KB
/
Copy pathDbMigrationHostedService.cs
File metadata and controls
153 lines (133 loc) · 5.73 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
using CLOOPS.NATS;
using CLOOPS.NATS.Locking;
using DbUp;
using DbUp.Engine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace CLOOPS.microservices;
/// <summary>
/// Runs DbUp SQL migrations from the app output migrations directory during host startup.
/// </summary>
public sealed class DbMigrationHostedService : IHostedService
{
private const string MigrationsDirectoryName = "migrations";
private static readonly TimeSpan MigrationLockTimeout = TimeSpan.FromMilliseconds(500);
private readonly BaseAppSettings appSettings;
private readonly IServiceProvider serviceProvider;
private readonly ILogger<DbMigrationHostedService> logger;
private readonly ILoggerFactory loggerFactory;
/// <summary>
/// Creates a hosted service that applies DbUp migrations during host startup.
/// </summary>
public DbMigrationHostedService(
BaseAppSettings appSettings,
IServiceProvider serviceProvider,
ILogger<DbMigrationHostedService> logger,
ILoggerFactory loggerFactory)
{
this.appSettings = appSettings;
this.serviceProvider = serviceProvider;
this.logger = logger;
this.loggerFactory = loggerFactory;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
var migrationsPath = Path.Combine(AppContext.BaseDirectory, MigrationsDirectoryName);
if (!Directory.Exists(migrationsPath))
{
logger.LogInformation("✅ Database migrations directory not found at {MigrationsPath}; skipping migrations", migrationsPath);
return;
}
if (!appSettings.EnableMigrations)
{
logger.LogWarning("⚠️ Database migrations directory found at {MigrationsPath}, but ENABLE_MIGRATIONS=False; skipping migrations", migrationsPath);
return;
}
if (string.IsNullOrWhiteSpace(appSettings.ConnectionString))
{
throw new InvalidOperationException("Database migrations are enabled and a migrations directory exists, but CNSTR is not configured.");
}
var sqlScripts = LoadSqlScripts(migrationsPath);
if (sqlScripts.Length == 0)
{
logger.LogInformation("✅ Database migrations directory found at {MigrationsPath}, but it contains no .sql files; skipping migrations", migrationsPath);
return;
}
var natsClient = serviceProvider.GetService<ICloopsNatsClient>();
if (!await BaseUtil.WaitForNatsConnectionAsync(natsClient, cancellationToken).ConfigureAwait(false))
{
logger.LogWarning("⚠️ Skipping database migrations because NATS is not ready after {NatsWaitTimeout}. Another pod may be applying migrations; ensure migrations are backward compatible.", BaseUtil.NatsConnectionWaitTimeout);
return;
}
var migrationLockKey = $"db-migrations.{appSettings.AssemblyName}";
DistributedLockHandle? handle;
try
{
handle = await natsClient!.AcquireDistributedLockAsync(
migrationLockKey,
MigrationLockTimeout,
ct: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning(ex, "⚠️ Skipping database migrations because distributed lock {MigrationLockKey} could not be acquired. Another pod may be applying migrations.", migrationLockKey);
return;
}
if (handle == null)
{
logger.LogWarning("⚠️ Skipping database migrations because distributed lock {MigrationLockKey} could not be acquired. Another pod may be applying migrations.", migrationLockKey);
return;
}
await using (handle)
{
RunMigrations(sqlScripts);
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private SqlScript[] LoadSqlScripts(string migrationsPath)
{
return Directory
.EnumerateFiles(migrationsPath, "*.sql", SearchOption.AllDirectories)
.Select(path => new
{
Path = path,
Name = Path.GetRelativePath(migrationsPath, path)
.Replace(Path.DirectorySeparatorChar, '/')
.Replace(Path.AltDirectorySeparatorChar, '/'),
})
.OrderBy(script => script.Name, StringComparer.OrdinalIgnoreCase)
.Select(script => new SqlScript(script.Name, File.ReadAllText(script.Path)))
.ToArray();
}
private void RunMigrations(SqlScript[] sqlScripts)
{
var upgrader = DeployChanges.To
.SqlDatabase(appSettings.ConnectionString)
.WithScripts(sqlScripts)
.LogTo(loggerFactory)
.LogScriptOutput()
.Build();
var pendingScripts = upgrader.GetScriptsToExecute();
if (pendingScripts.Count == 0)
{
logger.LogInformation("✅ Database schema is up to date; no migrations to apply");
return;
}
logger.LogInformation("✅ Applying {MigrationCount} database migration(s): {MigrationNames}",
pendingScripts.Count,
string.Join(", ", pendingScripts.Select(script => script.Name)));
var result = upgrader.PerformUpgrade();
if (!result.Successful)
{
logger.LogError(result.Error, "❌ Database migration failed");
throw new InvalidOperationException("Database migration failed.", result.Error);
}
logger.LogInformation("✅ Successfully applied {MigrationCount} database migration(s)", pendingScripts.Count);
}
}