Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions Morpheus.Tests/GuildServiceConcurrencyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Morpheus.Database;
using Morpheus.Database.Models;
using Morpheus.Services;

namespace Morpheus.Tests;

public class GuildServiceConcurrencyTests
{
[Fact]
public async Task TryGetCreateGuild_WhenAnotherHandlerCreatesGuild_ReturnsPersistedGuild()
{
await using SqliteConnection connection = new("Data Source=:memory:");
await connection.OpenAsync();

DbContextOptions<DB> options = new DbContextOptionsBuilder<DB>()
.UseSqlite(connection)
.Options;
await using (DB setup = new(options))
await setup.Database.EnsureCreatedAsync();

await using RacingDb db = new(options);
db.InsertCompetingGuildOnNextSave = true;
GuildPrefixService prefixService = new(null!);
GuildService service = new(db, new LogsService(new LogQueue()), prefixService);

Guild result = await service.TryGetCreateGuild(123, "current-name");

Assert.Equal((ulong)123, result.DiscordId);
Assert.Equal("current-name", result.Name);
Assert.Equal("persisted-prefix", await prefixService.GetPrefixAsync(123));
Assert.Equal(1, await db.Guilds.CountAsync());

db.ChangeTracker.Clear();
Guild persistedGuild = await db.Guilds.SingleAsync();
Assert.Equal("current-name", persistedGuild.Name);
}

private sealed class RacingDb(DbContextOptions<DB> options) : DB(options)
{
public bool InsertCompetingGuildOnNextSave { get; set; }

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
if (InsertCompetingGuildOnNextSave)
{
InsertCompetingGuildOnNextSave = false;
ChangeTracker.Clear();
Guilds.Add(new Guild
{
DiscordId = 123,
Name = "stale-name",
Prefix = "persisted-prefix"
});
await base.SaveChangesAsync(cancellationToken);
throw new DbUpdateException("Simulated concurrent unique-key conflict.");
}

return await base.SaveChangesAsync(cancellationToken);
}
}
}
24 changes: 23 additions & 1 deletion Services/GuildService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Morpheus.Database.Models;

namespace Morpheus.Services;

public class GuildService(DB dbContext, LogsService logsService, GuildPrefixService guildPrefixService)
{
public Task<Guild> TryGetCreateGuild(SocketGuild guild) =>
Expand Down Expand Up @@ -32,7 +33,28 @@ internal async Task<Guild> TryGetCreateGuild(ulong discordId, string name)
};

await dbContext.Guilds.AddAsync(guildDb);
await dbContext.SaveChangesAsync();
try
{
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Another handler may have created the same Discord guild after our initial lookup.
// Clear the failed insert and use the row protected by the unique DiscordId index.
dbContext.ChangeTracker.Clear();
Guild? concurrentGuild = await dbContext.Guilds.FirstOrDefaultAsync(g => g.DiscordId == discordId);
if (concurrentGuild == null)
throw;

if (concurrentGuild.Name != name)
{
concurrentGuild.Name = name;
await dbContext.SaveChangesAsync();
}

guildPrefixService.SetPrefix(discordId, concurrentGuild.Prefix);
return concurrentGuild;
}

logsService.Log($"New guild created {name}", Discord.LogSeverity.Verbose);
guildPrefixService.SetPrefix(discordId, guildDb.Prefix);
Expand Down