Harden owner and suggestion invariants for concurrent writes

This commit is contained in:
2026-02-08 21:37:46 +01:00
parent 569cea161f
commit fe6a9d5da4
13 changed files with 472 additions and 22 deletions

View File

@@ -68,7 +68,19 @@ public static class AuthEndpoints
};
db.Players.Add(player);
await db.SaveChangesAsync();
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName))
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race");
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername"))
{
return EndpointHelpers.ConflictError("Username already taken.");
}
if (isAdmin)
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);

View File

@@ -1,5 +1,6 @@
using GameList.Data;
using GameList.Domain;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Net.Sockets;
@@ -9,6 +10,9 @@ namespace GameList.Endpoints;
internal static class EndpointHelpers
{
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
{
if (ctx.User.Identity?.IsAuthenticated != true)
@@ -108,6 +112,20 @@ internal static class EndpointHelpers
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqliteException sqliteEx
&& sqliteEx.SqliteErrorCode == 19;
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
{
if (!IsSqliteConstraintViolation(ex))
return false;
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
}
private static IResult Problem(int statusCode, string title, string detail)
{
return Results.Problem(

View File

@@ -60,7 +60,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId);
var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5)
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
@@ -81,16 +81,24 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
db.Suggestions.Add(suggestion);
if (usingJoker)
try
{
await db.Players
.Where(p => p.Id == playerId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
}
await db.SaveChangesAsync();
await db.SaveChangesAsync();
await tx.CommitAsync();
if (usingJoker)
{
await db.Players
.Where(p => p.Id == playerId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
}
await tx.CommitAsync();
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
{
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
}
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
}

View File

@@ -2,6 +2,7 @@ using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace GameList.Endpoints;
@@ -71,26 +72,46 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
foreach (var linkedSuggestionId in linkedIds)
for (var attempt = 0; attempt < 2; attempt++)
{
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
if (vote == null)
foreach (var linkedSuggestionId in linkedIds)
{
db.Votes.Add(new Vote
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
if (vote == null)
{
PlayerId = playerId,
SuggestionId = linkedSuggestionId,
Score = score
});
db.Votes.Add(new Vote
{
PlayerId = playerId,
SuggestionId = linkedSuggestionId,
Score = score
});
}
else
{
vote.Score = score;
}
}
else
try
{
vote.Score = score;
await db.SaveChangesAsync();
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
}
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
{
DetachAddedVotes(db.ChangeTracker.Entries<Vote>());
await db.Votes
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score));
existingVotes = await db.Votes
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
}
}
await db.SaveChangesAsync();
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
return EndpointHelpers.ConflictError("Vote update conflict. Please retry.");
}
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
@@ -105,4 +126,13 @@ internal sealed class VoteWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
}
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)
{
foreach (var entry in voteEntries)
{
if (entry.State == EntityState.Added)
entry.State = EntityState.Detached;
}
}
}