signup: close two concurrency holes from final review
(1) RegisterAnonymousViewer now catches the unique-violation
race (SQLSTATE 23505 on Postgres / code 19 on SQLite) and
re-reads by UDID, returning the existing row instead of
surfacing 500 to the second concurrent /tool/signup caller.
New repo test exercises the back-to-back register path.
(2) Add unique index on SocialAccountConnection (AccountType,
AccountId). The auth handler's find-or-link path claimed
this index existed as the dedup backstop; the claim was
accurate as design intent but the schema was missing. Now
matched. Comment in handler updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
@@ -98,10 +99,52 @@ public class ViewerRepository : IViewerRepository
|
||||
var viewer = await BuildDefaultViewer("Player");
|
||||
viewer.Udid = udid;
|
||||
_dbContext.Set<Models.Viewer>().Add(viewer);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
try
|
||||
{
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Concurrent signup for the same UDID raced us to the unique index. The other request
|
||||
// already committed a viewer with this UDID — re-read and return it. Detach the local
|
||||
// entity first so EF doesn't keep trying to insert the now-orphaned graph.
|
||||
//
|
||||
// Cross-engine: Postgres surfaces this as Npgsql.PostgresException SqlState "23505";
|
||||
// SQLite (test backend) surfaces it as Microsoft.Data.Sqlite.SqliteException with
|
||||
// SqliteErrorCode 19 (SQLITE_CONSTRAINT). Matched by type-name to avoid pulling a
|
||||
// Sqlite package dep into SVSim.Database.
|
||||
_dbContext.Entry(viewer).State = EntityState.Detached;
|
||||
var existing = await GetViewerByUdid(udid)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Got unique-violation on Udid={udid} insert but subsequent lookup found no row. " +
|
||||
"This shouldn't happen — likely transaction isolation issue.");
|
||||
return existing;
|
||||
}
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given <see cref="DbUpdateException"/> wraps a backend-level unique-
|
||||
/// constraint violation. Postgres → SqlState "23505"; SQLite → SqliteErrorCode 19.
|
||||
/// </summary>
|
||||
private static bool IsUniqueViolation(DbUpdateException ex)
|
||||
{
|
||||
if (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Match SQLite by type name so this assembly doesn't take a dep on Microsoft.Data.Sqlite.
|
||||
// Test backend (SQLite in-memory) raises SqliteException with SqliteErrorCode 19 on UNIQUE
|
||||
// constraint violations.
|
||||
var inner = ex.InnerException;
|
||||
if (inner is not null && inner.GetType().FullName == "Microsoft.Data.Sqlite.SqliteException")
|
||||
{
|
||||
var prop = inner.GetType().GetProperty("SqliteErrorCode");
|
||||
if (prop?.GetValue(inner) is int code && code == 19) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task LinkSteamToViewer(long viewerId, ulong steamId)
|
||||
{
|
||||
var viewer = await _dbContext.Set<Models.Viewer>()
|
||||
|
||||
Reference in New Issue
Block a user