From cf7a0218985fdb62e8a26678247825be186155bf Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:52:53 +0200 Subject: [PATCH] fix: repair a missing entry sub-room under the bootstrap lock From the local CodeRabbit review: - Repair rooms that lack an OrderIndex 0 (entry) sub-room even when they have other sub-rooms, not only rooms with none. - Every service runs the database bootstrap at startup, so on Postgres replicas booting together could hand out the same scene ids (max + 1) and one would fail startup on the primary key. Run the repair in a transaction holding the bootstrap's advisory lock. --- .../SubRoomsAndInstances2023Tests.cs | 18 ++++++++++++++ DorkNet.Server/Services/RoomService.cs | 24 +++++++++++++------ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs b/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs index c1f83be..092ea5f 100644 --- a/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs +++ b/DorkNet.Server.Tests/SubRoomsAndInstances2023Tests.cs @@ -118,6 +118,24 @@ public sealed class SubRoomsAndInstances2023Tests : IClassFixture(); + await db.RoomScenes.Where(s => s.RoomId == roomId && s.OrderIndex == 0).ExecuteDeleteAsync(); + + var service = scope.ServiceProvider.GetRequiredService(); + var (added, _) = await service.RepairRoomScenesAsync(); + Assert.True(added >= 1); + + var scenes = await db.RoomScenes.AsNoTracking().Where(s => s.RoomId == roomId) + .OrderBy(s => s.OrderIndex).Select(s => new { s.OrderIndex, s.Name }).ToListAsync(); + Assert.Equal(new[] { (0, "Home"), (1, "Back") }, scenes.Select(s => (s.OrderIndex, s.Name))); + Assert.Equal((0, 0), await service.RepairRoomScenesAsync()); + } + [Fact] public async Task Instance_browser_lists_the_private_instance_with_its_players_for_allowed_callers() { diff --git a/DorkNet.Server/Services/RoomService.cs b/DorkNet.Server/Services/RoomService.cs index 385a4a9..5406386 100644 --- a/DorkNet.Server/Services/RoomService.cs +++ b/DorkNet.Server/Services/RoomService.cs @@ -585,15 +585,24 @@ public class RoomService(DorkNetDbContext db) /// /// Scene ids are assigned explicitly (max + 1) for the same reason /// the clone handler does: seeded rows were inserted with manual ids - /// without advancing the Postgres identity sequence. - /// Returns (rooms given a scene, scenes renamed). + /// without advancing the Postgres identity sequence. Every service that + /// runs the database bootstrap calls this at startup, so on Postgres the + /// whole repair holds the bootstrap's advisory lock: replicas booting + /// together take turns instead of handing out the same ids. + /// Returns (rooms given an entry scene, scenes renamed). public async Task<(int ScenesAdded, int ScenesRenamed)> RepairRoomScenesAsync() { - var roomsWithoutScenes = await db.Rooms - .Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id)) + await using var tx = await db.Database.BeginTransactionAsync(); + if (db.Database.IsNpgsql()) + await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1146246987);"); + + // The entry sub-room is OrderIndex 0 (its blob is the room's own front + // door), so a room missing it is repaired even when it has others. + var roomsWithoutEntry = await db.Rooms + .Where(r => !r.IsDormRoom && !db.RoomScenes.Any(s => s.RoomId == r.Id && s.OrderIndex == 0)) .ToListAsync(); var nextSceneId = await db.RoomScenes.MaxAsync(s => (long?)s.Id) ?? 0L; - foreach (var room in roomsWithoutScenes) + foreach (var room in roomsWithoutEntry) { var scene = NewHomeScene(room); scene.Id = ++nextSceneId; @@ -606,9 +615,10 @@ public class RoomService(DorkNetDbContext db) foreach (var scene in unnamed) scene.Name = DefaultSubRoomName(scene.OrderIndex); - if (roomsWithoutScenes.Count > 0 || unnamed.Count > 0) + if (roomsWithoutEntry.Count > 0 || unnamed.Count > 0) await db.SaveChangesAsync(); - return (roomsWithoutScenes.Count, unnamed.Count); + await tx.CommitAsync(); + return (roomsWithoutEntry.Count, unnamed.Count); } /// Run once at startup to backfill