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