From 012d1cb926f905dc5d982de676082e0ebf0dfb1c Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 22:13:59 +0000 Subject: [PATCH 1/2] Test: a second simultaneous close of a Standard motion must not throw Fails at this commit with DbUpdateConcurrencyException from CloseMotionAsync: Motion.Status became a concurrency token in BifrostDbContext.cs:288. Co-Authored-By: Claude Sonnet 5.5 --- .../StandardMotionCloseTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/Bifrost.Web.Tests/StandardMotionCloseTests.cs diff --git a/tests/Bifrost.Web.Tests/StandardMotionCloseTests.cs b/tests/Bifrost.Web.Tests/StandardMotionCloseTests.cs new file mode 100644 index 0000000..21a95e9 --- /dev/null +++ b/tests/Bifrost.Web.Tests/StandardMotionCloseTests.cs @@ -0,0 +1,49 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Tests; + +public sealed class StandardMotionCloseTests +{ + private static readonly DateTimeOffset T0 = new(2026, 11, 10, 12, 0, 0, TimeSpan.Zero); + + private sealed class Clock(DateTimeOffset start) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => start; + } + + [Fact] + public async Task A_second_simultaneous_close_adopts_the_winners_close_and_records_nothing_more() + { + var clock = new Clock(T0); + var name = $"standard-close-race-{Guid.NewGuid():N}"; + BifrostDbContext NewDb() => new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + MotionGovernanceService NewService(BifrostDbContext db) => new(db, new AuditTrailService(db, clock), clock); + + await using var seed = NewDb(); + var user = new UserAccount { DisplayName = "Mod", GitHubLogin = "mod", BifrostIdentity = "mod", NormalizedBifrostIdentity = "MOD", NormalizedGitHubLogin = "MOD" }; + user.Membership = new Membership { UserAccountId = user.Id, Status = MembershipStatus.Active, CanModerateMotions = true }; + seed.UserAccounts.Add(user); + await seed.SaveChangesAsync(); + var actor = new CurrentBifrostActor(true, user); + var motion = await NewService(seed).CreateMotionAsync( + actor, + new CreateMotionCommand(MotionScope.Management, null, MotionCategory.Features, "Race", "Two closers.", T0.AddDays(1)), + default); + + await using var first = NewDb(); + await using var second = NewDb(); + _ = await second.Motions.ToListAsync(); // second now holds a stale, still-Open copy + + await NewService(first).CloseMotionAsync(actor, motion.Id, default); + await NewService(second).CloseMotionAsync(actor, motion.Id, default); // the Status token refuses this write + + await using var check = NewDb(); + var row = await check.Motions.SingleAsync(); + Assert.Equal(MotionStatus.Closed, row.Status); + Assert.Equal(1, await check.AuditEvents.CountAsync(x => x.Action == "motion.closed")); + } +} -- 2.47.3 From ee3a2f49f57f3a7bdb0e30db72fb9e6520f830cc Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 22:13:59 +0000 Subject: [PATCH 2/2] Standard close: the loser of a simultaneous close adopts the winner's result Status is a concurrency token, so the second SaveChanges of two simultaneous CloseMotionAsync calls throws DbUpdateConcurrencyException. Reload and return, with no second motion.closed audit event; the same pattern as the sealed closer. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Motions/MotionGovernanceService.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index 983526c..bbb1d6b 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -126,7 +126,21 @@ public sealed class MotionGovernanceService( ? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold." : "Closed without decisive votes."; - await dbContext.SaveChangesAsync(cancellationToken); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException error) + { + // Status is a concurrency token: another closer won. Adopt its result and record nothing a second time. + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } + + return; + } + await auditTrailService.RecordAsync( actorUser.Id, nameof(Motion), -- 2.47.3