diff --git a/Morpheus.Tests/UsersServiceTests.cs b/Morpheus.Tests/UsersServiceTests.cs index 9f25960..780be24 100644 --- a/Morpheus.Tests/UsersServiceTests.cs +++ b/Morpheus.Tests/UsersServiceTests.cs @@ -32,6 +32,29 @@ public async Task TryGetCreateUser_CreatesUserFromNonSocketUser() Assert.Single(await db.Users.ToListAsync()); } + [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 static IUser CreateUser(ulong id, string username) { IUser user = DispatchProxy.Create(); @@ -53,4 +76,31 @@ public class UserProxy : DispatchProxy _ => throw new NotSupportedException(targetMethod?.Name) }; } + + 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); + } + } } diff --git a/Services/UsersService.cs b/Services/UsersService.cs index 6fe463b..560269a 100644 --- a/Services/UsersService.cs +++ b/Services/UsersService.cs @@ -7,25 +7,42 @@ namespace Morpheus.Services; public class UsersService(DB dbContext, LogsService logsService) { - public async Task TryGetCreateUser(IUser user) + public Task TryGetCreateUser(IUser 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; }