fix: sub-room id sequence, restore sync, presence index TTL, permission races

From the local CodeRabbit review:
- Scene ids: the repair and room clone assigned max + 1, which never
  advanced the Postgres identity sequence (live: sequence 3412, max id 3466),
  so the next add / clone sub-room would collide on the primary key. The
  repair now moves the sequence past the highest id at startup, and both
  let the database assign ids.
- Room-level restores (datahistory/restore and the admin restore) now also
  restore the entry sub-room, whose blob room details serve first.
- TouchRoom refreshes the room's presence index too, so players kept alive
  only by heartbeats no longer drop out of the instance browser after 20 min.
- Sub-room permission saves match cells exactly (as the unique index does)
  and retry when a concurrent editor inserted the same new cell.
This commit is contained in:
Alexa
2026-10-25 13:59:30 +02:00
parent 9076fb9fae
commit 8be0f90dbc
5 changed files with 129 additions and 43 deletions
@@ -1406,6 +1406,7 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(room.Id, blob.BlobName);
await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so
@@ -1479,10 +1480,27 @@ public class RoomsController(
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
if (!room.IsDormRoom) await SyncEntrySceneBlobAsync(roomId, blob.BlobName);
await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName });
}
/// <summary>A room-level restore also restores the entry sub-room
/// (OrderIndex 0): room details serve the entry scene's own blob ahead
/// of the room's, so updating only <c>Rooms.CurrentDataBlobName</c>
/// would leave the old version in place. The per-sub-room restore keeps
/// the two in step the other way round. Dorms keep their blob per
/// player (DormState) instead.</summary>
private Task SyncEntrySceneBlobAsync(long roomId, string blobName)
{
var now = DateTime.UtcNow;
return db.RoomScenes
.Where(s => s.RoomId == roomId && s.OrderIndex == 0)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.DataBlobName, blobName)
.SetProperty(x => x.DataModifiedAt, now));
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
@@ -3829,35 +3847,51 @@ public class RoomsController(
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
if (scene is null) return NotFound();
var existing = await db.RoomRolePermissions
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
.ToListAsync();
var now = DateTime.UtcNow;
foreach (var cell in cells)
{
var row = existing.FirstOrDefault(p =>
p.Role == cell.Role
&& string.Equals(p.Permission, cell.Permission, StringComparison.OrdinalIgnoreCase));
if (row is null)
{
row = new RoomRolePermissionEntity
{
RoomId = roomId,
SubRoomId = subRoomId,
Role = cell.Role,
Permission = cell.Permission,
};
db.RoomRolePermissions.Add(row);
existing.Add(row);
}
row.Type = cell.Type;
row.Override = cell.Override;
row.Value = cell.Value;
row.UpdatedByPlayerId = pid;
row.UpdatedAt = now;
}
scene.DataModifiedAt = now;
await db.SaveChangesAsync();
for (var attempt = 1; ; attempt++)
{
var existing = await db.RoomRolePermissions
.Where(p => p.RoomId == roomId && p.SubRoomId == subRoomId)
.ToListAsync();
foreach (var cell in cells)
{
// Exact match, like the unique index on
// (RoomId, SubRoomId, Role, Permission).
var row = existing.FirstOrDefault(p =>
p.Role == cell.Role && string.Equals(p.Permission, cell.Permission, StringComparison.Ordinal));
if (row is null)
{
row = new RoomRolePermissionEntity
{
RoomId = roomId,
SubRoomId = subRoomId,
Role = cell.Role,
Permission = cell.Permission,
};
db.RoomRolePermissions.Add(row);
existing.Add(row);
}
row.Type = cell.Type;
row.Override = cell.Override;
row.Value = cell.Value;
row.UpdatedByPlayerId = pid;
row.UpdatedAt = now;
}
try
{
await db.SaveChangesAsync();
break;
}
catch (DbUpdateException) when (attempt < 3)
{
// Another editor inserted the same new cell between our read
// and our insert (unique index). Forget our pending rows and
// apply the cells again, now as updates of theirs.
foreach (var entry in db.ChangeTracker.Entries<RoomRolePermissionEntity>().ToList())
entry.State = EntityState.Detached;
}
}
logger.LogInformation(
"[subroom-permissions] room={Room} sub={Sub} by={By} cells={Count}",