diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index 038304e0..b5140485 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -118,4 +118,59 @@ public static async Task ApplyPendingOpenShockMigrations(th return app; } + + /// + /// Blocks startup until the database schema is up to date (no pending migrations), for hosts that do + /// not own migrations (e.g. the Cron worker - the API is the sole migrator). This is not just + /// tidiness: binds Postgres enum types (email_status, + /// email_type, ...) to CLR enums by name, and Npgsql resolves those names against the + /// type catalog it loads on the data source's first connection and caches for the life of the process. + /// If that first connection happens before the migrator has created a newly-added enum, every query + /// using it fails permanently ("data type name '...' could not be found") until the process restarts. + /// Waiting here guarantees the schema - and its enum types - exists before anything opens the pooled + /// context, closing the deploy-time race without this host performing any schema writes of its own. + /// + public static async Task WaitForOpenShockSchemaReady(this IApplicationBuilder app, + DatabaseOptions options, TimeSpan? timeout = null) + { + using var scope = app.ApplicationServices.CreateScope(); + var loggerFactory = scope.ServiceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger("SchemaReadyGate"); + + logger.LogInformation("Waiting for database schema to be up to date (migrations owned by another host)..."); + + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromMinutes(5)); + var delay = TimeSpan.FromSeconds(1); + var maxDelay = TimeSpan.FromSeconds(15); + + while (true) + { + try + { + await using var migrationContext = new MigrationOpenShockContext(options.Conn, options.Debug, loggerFactory); + var pending = (await migrationContext.Database.GetPendingMigrationsAsync()).ToArray(); + if (pending.Length == 0) + { + logger.LogInformation("Database schema is up to date; proceeding with startup"); + return app; + } + + logger.LogWarning("Schema not ready: {Count} pending migration(s) [{Migrations}] not yet applied by the migrator", + pending.Length, string.Join(", ", pending)); + } + catch (Exception ex) + { + // Database not reachable yet, or the migrator is mid-run. Keep waiting rather than crash-looping. + logger.LogWarning(ex, "Could not determine migration state; will retry"); + } + + if (DateTime.UtcNow >= deadline) + throw new TimeoutException( + "Timed out waiting for the database schema to be brought up to date by the migrator. " + + "Ensure the API host (the sole migrator) is running and reachable."); + + await Task.Delay(delay); + delay = TimeSpan.FromSeconds(Math.Min(maxDelay.TotalSeconds, delay.TotalSeconds * 2)); + } + } } \ No newline at end of file diff --git a/Cron/Program.cs b/Cron/Program.cs index 4846e81e..d70055f0 100644 --- a/Cron/Program.cs +++ b/Cron/Program.cs @@ -44,6 +44,13 @@ await app.UseCommonOpenShockMiddleware(); +// The Cron host does not own migrations (the API is the sole migrator). Its OpenShockContext binds +// Postgres enum types by name at the pooled data source's first connection and caches them for the +// process's life, so it must not open that context before a newly-added enum exists - otherwise every +// claim query fails permanently ("data type name 'email_status' could not be found"). Block until the +// migrator has applied all pending migrations, which happens before Hangfire or any job runs below. +await app.WaitForOpenShockSchemaReady(databaseOptions); + var hangfireOptions = new DashboardOptions(); if (app.Environment.IsProduction() || Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true") {