From bb22675a914457a875e55a95f4555382f4434175 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:01:43 +0200 Subject: [PATCH] fix: hold the Postgres bootstrap lock across schema creation and seeding Booting several replicas against one fresh Postgres database left all but one dead on startup: the advisory lock only covered EnsureCreated, so the seeders (system account, rooms, playlists, store, ...) raced and hit duplicate keys. Take a session-level advisory lock for the whole bootstrap and release it explicitly (pooled connections would otherwise keep it). The upgrades and seeds both providers run move into RunUpgradesAndSeedsAsync. --- DorkNet.Server/Startup/DatabaseBootstrap.cs | 124 ++++++++++++-------- 1 file changed, 73 insertions(+), 51 deletions(-) diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index bb5b946..13489a7 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -15,9 +15,9 @@ namespace DorkNet.Server.Startup; /// SQLite — Database.Migrate() after /// reconciles any legacy /// migration-history state. -/// Postgres — EnsureCreated behind a transaction-scoped -/// advisory lock so concurrent replica boots don't race on -/// CREATE TABLE. Connect retries cover the +/// Postgres — EnsureCreated, with the whole bootstrap +/// (schema and seeds) behind a session advisory lock so concurrent +/// replica boots take turns instead of racing. Connect retries cover the /// container-orchestrated boot window where Postgres DNS isn't /// resolvable yet. /// @@ -40,62 +40,84 @@ public static class DatabaseBootstrap await ConfigureSqliteForLocalConcurrencyAsync(db); BaselineExistingSchemaIfNeeded(db); db.Database.Migrate(); + await RunUpgradesAndSeedsAsync(app, scope, db); + return; } - else + + // Postgres path: schema generated from the EF Core model via + // EnsureCreated, then the same upgrades and seeds as SQLite. The + // whole bootstrap holds a session-level advisory lock, so replicas + // booting against one database take turns: the first creates the + // schema and seeds, the rest find everything in place. Holding it + // only around EnsureCreated left the seeders racing (duplicate keys + // on a fresh database). + // + // Lock key 0x444F524B (ascii "DORK") is hard-coded; it just needs + // to be the same int64 across replicas and not collide with + // anything else in the database. It is released in the finally + // below, and Postgres drops it if the replica's connection dies, so + // a crashed replica can't wedge the next boot. + var conn = db.Database.GetDbConnection(); + + // Retry initial Postgres connect — on container-orchestrated boots + // (Coolify, docker-compose) the Postgres service's DNS often isn't + // resolvable yet when this app starts, surfacing as SocketException + // errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore. + // Postgres itself can also be up-but-not-accepting-connections for + // a few seconds while it replays WAL. 15 attempts × ~2s backoff + // covers the typical 5-30s warmup window without looping forever + // on a permanently broken config. + var connectLogger = app.Services.GetRequiredService() + .CreateLogger("DorkNet.Server.Bootstrap"); + for (var attempt = 1; ; attempt++) { - // Postgres path: schema generated from the EF Core model via - // EnsureCreated. Wrapped in a transaction-scoped advisory lock - // so concurrent replicas booting against the same database - // don't race on CREATE TABLE — only the first replica to take - // the lock runs EnsureCreated, the rest see "schema already - // exists" and proceed. - // - // Lock key 0x444F524B (ascii "DORK") is hard-coded; it just - // needs to be the same int64 across replicas and not collide - // with anything else in the database. pg_advisory_xact_lock - // releases automatically on COMMIT, so a crashed replica can't - // wedge the next boot. - var conn = db.Database.GetDbConnection(); - - // Retry initial Postgres connect — on container-orchestrated boots - // (Coolify, docker-compose) the Postgres service's DNS often isn't - // resolvable yet when this app starts, surfacing as SocketException - // errno=11 EAGAIN from Npgsql's Dns.GetHostEntryOrAddressesCore. - // Postgres itself can also be up-but-not-accepting-connections for - // a few seconds while it replays WAL. 15 attempts × ~2s backoff - // covers the typical 5-30s warmup window without looping forever - // on a permanently broken config. - var connectLogger = app.Services.GetRequiredService() - .CreateLogger("DorkNet.Server.Bootstrap"); - for (var attempt = 1; ; attempt++) + try { await conn.OpenAsync(); break; } + catch (Exception ex) when (attempt < 15 && + (ex is System.Net.Sockets.SocketException + || ex is Npgsql.NpgsqlException + || ex is System.Data.Common.DbException)) { - try { await conn.OpenAsync(); break; } - catch (Exception ex) when (attempt < 15 && - (ex is System.Net.Sockets.SocketException - || ex is Npgsql.NpgsqlException - || ex is System.Data.Common.DbException)) - { - var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5)); - connectLogger.LogWarning( - "[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s", - attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds); - await Task.Delay(delay); - } + var delay = TimeSpan.FromSeconds(Math.Min(attempt, 5)); + connectLogger.LogWarning( + "[bootstrap] Postgres connect attempt {Attempt}/15 failed ({Type}: {Message}); retrying in {Delay}s", + attempt, ex.GetType().Name, ex.Message, delay.TotalSeconds); + await Task.Delay(delay); } - // Begin the transaction through EF, not on the raw connection: - // since EF Core 10, EnsureCreated runs its commands through the - // migration executor, which starts its own transaction unless EF - // already has one, and fails on a fresh database with "A - // transaction is already in progress" when the open one is - // invisible to it. - await using var tx = await db.Database.BeginTransactionAsync(); - await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1146246987);"); - db.Database.EnsureCreated(); - await tx.CommitAsync(); + } + // The connection stays open until the finally, so every step below + // (they all share this scope's DbContext) runs on the session that + // holds the lock. + await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_lock(1146246987);"); + try + { + // Begin the transaction through EF, not on the raw connection: + // EnsureCreated runs its commands through the migration executor, + // which starts its own transaction unless EF already has one, and + // fails on a fresh database with "A transaction is already in + // progress" when the open one is invisible to it. + await using (var tx = await db.Database.BeginTransactionAsync()) + { + db.Database.EnsureCreated(); + await tx.CommitAsync(); + } + await RunUpgradesAndSeedsAsync(app, scope, db); + } + finally + { + // Explicit: Npgsql pools connections, so closing alone would not + // end the session that holds the lock. + await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_unlock(1146246987);"); await conn.CloseAsync(); } + } + /// The idempotent post-schema sequence both providers run: + /// column/table upgrades, system account and backfills, legacy data + /// transforms, canonical rooms and the per-feature seeders. Flips the + /// /healthz readiness flag at the end. + private static async Task RunUpgradesAndSeedsAsync(WebApplication app, IServiceScope scope, DorkNetDbContext db) + { // Signup-code + pending-device tables. These post-date the // consolidated Initial migration, and the Postgres path is // EnsureCreated-only (no migration replay), so neither provider