From f6ad1b4a357b03d7bcb42b342acf3f38bf22be87 Mon Sep 17 00:00:00 2001 From: vycdev2 Date: Sun, 9 Aug 2026 19:30:29 +0000 Subject: [PATCH] fix: tolerate concurrent user creation --- Morpheus.Tests/UsersServiceTests.cs | 60 +++++++++++++++++++++++++++++ Services/UsersService.cs | 29 +++++++++++--- 2 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 Morpheus.Tests/UsersServiceTests.cs diff --git a/Morpheus.Tests/UsersServiceTests.cs b/Morpheus.Tests/UsersServiceTests.cs new file mode 100644 index 0000000..37d5421 --- /dev/null +++ b/Morpheus.Tests/UsersServiceTests.cs @@ -0,0 +1,60 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Morpheus.Database; +using Morpheus.Database.Models; +using Morpheus.Services; + +namespace Morpheus.Tests; + +public class UsersServiceTests +{ + [Fact] + public async Task TryGetCreateUserAsync_WhenAnotherHandlerCreatesUser_ReturnsPersistedUser() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using (DB setup = new(options)) + await setup.Database.EnsureCreatedAsync(); + + await using RacingDb db = new(options); + db.InsertCompetingUserOnNextSave = true; + UsersService service = new(db, new LogsService(new LogQueue())); + + User result = await service.TryGetCreateUserAsync(123, "first"); + + Assert.Equal((ulong)123, result.DiscordId); + Assert.Equal("concurrent", result.Username); + Assert.Equal(1, await db.Users.CountAsync()); + } + + private sealed class RacingDb : DB + { + public RacingDb(DbContextOptions options) : base(options) + { + } + + public bool InsertCompetingUserOnNextSave { get; set; } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + if (InsertCompetingUserOnNextSave) + { + InsertCompetingUserOnNextSave = false; + ChangeTracker.Clear(); + Users.Add(new User + { + DiscordId = 123, + Username = "concurrent" + }); + await base.SaveChangesAsync(cancellationToken); + throw new DbUpdateException("Simulated concurrent unique-key conflict."); + } + + return await base.SaveChangesAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/Services/UsersService.cs b/Services/UsersService.cs index 0ef3c9b..a33eebe 100644 --- a/Services/UsersService.cs +++ b/Services/UsersService.cs @@ -6,25 +6,42 @@ namespace Morpheus.Services; public class UsersService(DB dbContext, LogsService logsService) { - public async Task TryGetCreateUser(SocketUser user) + public Task TryGetCreateUser(SocketUser user) => + TryGetCreateUserAsync(user.Id, user.Username); + + internal async Task TryGetCreateUserAsync(ulong discordId, string username) { - User? userDb = await dbContext.Users.FirstOrDefaultAsync(u => u.DiscordId == user.Id); + User? userDb = await dbContext.Users.FirstOrDefaultAsync(u => u.DiscordId == discordId); if (userDb != null) return userDb; userDb = new User() { - DiscordId = user.Id, - Username = user.Username, + DiscordId = discordId, + Username = username, InsertDate = DateTime.UtcNow, LastUsernameCheck = DateTime.UtcNow }; await dbContext.Users.AddAsync(userDb); - await dbContext.SaveChangesAsync(); + try + { + await dbContext.SaveChangesAsync(); + } + catch (DbUpdateException) + { + // Another message handler may have created the same Discord user after our initial + // lookup. Re-query instead of failing activity processing on the unique index. + dbContext.ChangeTracker.Clear(); + User? concurrentUser = await dbContext.Users.FirstOrDefaultAsync(u => u.DiscordId == discordId); + if (concurrentUser != null) + return concurrentUser; + + throw; + } - logsService.Log($"New user created {user.Username}", Discord.LogSeverity.Verbose); + logsService.Log($"New user created {username}", Discord.LogSeverity.Verbose); return userDb; }