From 9b2472c528bed8f18cc44ec9ba73d99f12ee7851 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 1 Jul 2026 12:30:32 +0200 Subject: [PATCH] fix(cron): gate startup on schema readiness before opening the pooled context The Cron host does not own migrations (the API is the sole migrator), but its OpenShockContext binds Postgres enum types (email_status, email_type, ...) to CLR enums by name via MapEnum(). Npgsql resolves those names against the type catalog it loads on the pooled 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 with 'data type name ... could not be found in the types that were loaded by Npgsql' until the process restarts - which is exactly what hit the EmailOutboxDeliveryJob claim query during the outbox rollout. Add WaitForOpenShockSchemaReady (polls GetPendingMigrationsAsync with capped backoff and a timeout) and call it in the Cron host right after the common middleware, before Hangfire or any job opens the pooled context. Cron performs no schema writes; the API stays the sole migrator. --- Common/OpenShockMiddlewareHelper.cs | 55 +++++++++++++++++++++++++++++ Cron/Program.cs | 7 ++++ 2 files changed, 62 insertions(+) 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") {