There's many ways to deploy pending Entity Framework Core (EF Core) migrations, especially for multi-tenanted scenarios. In this post, I'll demonstrate a strategy to efficiently apply pending EF Core 6 migrations using a .NET 6 console app.
Update: This post has been updated to use .NET 6 and Entity Framework Core 6.
Previous Article: Entity Framework 6 Migration Deployment
I recently created a post that demonstrated a strategy to deploy Entity Framework 6 (EF6) migrations using a console app. Now that EF Core has become the preferred ORM these days, I also wanted to see how this strategy looked using EF Core 6.
Code
You can find the code for this EF Core example here.
Multi-Tenanted Databases
Just like my post using EF6, for the purposes of this post, multi-tenanted refers to each tenant having its own database or connection string that's compatible with a single DbContext.
Applying EF Core Migrations with a .NET 6 Console App
There's several ways to apply pending migrations, but for this post I'll be creating a .NET 6 console app that's dedicated to applying migrations to several tenant databases.
Main Method
My console app's Main
method outlines what I want to accomplish.
List<MigratorTenantInfo> tenants = GetConfiguredTenants();
IEnumerable<Task> tasks = tenants.Select(t => MigrateTenantDatabase(t));
try
{
Logger.Information("Starting parallel execution of pending migrations...");
await Task.WhenAll(tasks);
}
catch
{
Logger.Warning("Parallel execution of pending migrations is complete with error(s).");
return (int)ExitCode.Error;
}
Logger.Information("Parallel execution of pending migrations is complete.");
return (int)ExitCode.Success;
I want to 1) get a list of my tenants from configuration, 2) execute the migrations for each of these tenants, and 3) tell the console app runner, whether that's my host OS or the CI/CD pipeline, whether all the migrations were applied successfully or an error was encountered.
I'm able to apply each set of tenant migrations in parallel by creating a list of Task
's, where each Task
is the asynchronous MigrateTenantDatabase
method.
Getting the Configured Tenants
To gather information about each tenant, I have an appsettings.json file like this:
{
"MigratorTenantInfo": [
{
"Name": "Default",
"ConnectionString": "Server=(LocalDb)\\MSSQLLocalDB;Database=DefaultEfCoreContext;Trusted_Connection=True;"
},
{
"Name": "ExtremeGolf",
"ConnectionString": "Server=(LocalDb)\\MSSQLLocalDB;Database=EfCoreDbContextExtremeGolf;Trusted_Connection=True;"
},
...
My GetConfiguredTenants
method yields a strongly typed list of tenants:
List<MigratorTenantInfo> GetConfiguredTenants()
{
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appSettings.json", optional: false);
IConfiguration config = builder.Build();
return config.GetSection(nameof(MigratorTenantInfo)).Get<List<MigratorTenantInfo>>();
Migrating Each Tenant Database
async Task MigrateTenantDatabase(MigratorTenantInfo tenant)
{
using var logContext = LogContext.PushProperty("TenantName", $"({tenant.Name}) ");
DbContextOptions dbContextOptions = CreateDefaultDbContextOptions(tenant.ConnectionString);
try
{
using var context = new EfCoreDbContext(dbContextOptions);
await context.Database.MigrateAsync();
}
catch (Exception e)
{
Logger.Error(e, "Error occurred during migration");
throw;
}
}
DbContextOptions CreateDefaultDbContextOptions(string connectionString) =>
new DbContextOptionsBuilder()
.LogTo(action: Logger.Information, filter: MigrationInfoLogFilter(), options: DbContextLoggerOptions.None)
.UseSqlServer(connectionString)
.Options;
To migrate a tenant database, I create a DbContextOptions
with the tenant database connection string, and do some plumbing with the logging setup. I'll explain more about the logging later.
Using the DbContextOptions
instance I created, I instantiate a new DbContext
, in my case the EfCoreDbContext
, and call the asynchronous method of my context's Database
property, MigrateAsync()
.
If an error is encountered during the migration, I'll catch the exception, log the error to the console, and propagate the exception back up the caller.
Logging
While the console app is running and applying the migrations, I want to know what's happening in real time. EF Core offers a bit of logging of its internals out of the box. However, for applying migrations, I found the logging too excessive for my use case. My solution was to supply the LogTo
method within the DbContextOptionsBuilder
some additional parameters for filtering the logging output.
.LogTo(action: Logger.Information, filter: MigrationInfoLogFilter(), options: DbContextLoggerOptions.None)
Logger.Information
I've configured the console app to use Serilog. I set up an ILogger
instance as follows.
static readonly ILogger Logger = Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {TenantName}{Message:lj}{NewLine}{Exception}")
.CreateLogger();
You may have noticed above in the MigrateTenantDatabase
method, I pushed the property "TenantName"
to the static LogContext
. This is so I can associate the logging output to a specific tenant. This was especially important to me in order to make sense of the logging output when the migrations could be running in parallel.
My Logger.Information
method is what I passed as my logging action delegate to the LogTo
method.
Filtering with MigrationInfoLogFilter()
I want my logging output to only contain information about the migration in progress at the informational level. Otherwise, the only other logging output I want to know about needs to be above the informational level.
Func<EventId, LogLevel, bool> MigrationInfoLogFilter() => (eventId, level) =>
level > LogLevel.Information ||
(level == LogLevel.Information &&
new[]
{
RelationalEventId.MigrationApplying,
RelationalEventId.MigrationAttributeMissingWarning,
RelationalEventId.MigrationGeneratingDownScript,
RelationalEventId.MigrationGeneratingUpScript,
RelationalEventId.MigrationReverting,
RelationalEventId.MigrationsNotApplied,
RelationalEventId.MigrationsNotFound,
RelationalEventId.MigrateUsingConnection
}.Contains(eventId));
I'm able to filter the specific events by supplying my desired EventId
's from EF Core's RelationEventId
static class. The result of this method is what I pass to LogTo
.
Conclusion
And that about wraps it up. Here's my output after running the console app.
[15:21:40 INF] Starting parallel execution of pending migrations...
[15:21:42 INF] (ExtremeGolf) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (Hole19) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (AlbatrossWasHere) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (AugustaWho) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (BirdiesRUs) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (HeadcoverCentral) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (Default) Applying migration '20210411140109_InitialCreate'.
[15:21:42 INF] (BirdiesRUs) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (Hole19) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (HeadcoverCentral) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (ExtremeGolf) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (AugustaWho) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (Default) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] (AlbatrossWasHere) Applying migration '20210411164415_AddUserNameColumn'.
[15:21:42 INF] Parallel execution of pending migrations is complete.
Seven tenant databases migrated in under two seconds; I can't complain!
Happy Coding!