From c253930d59b9af4cef20a93aff335e5a68af9df3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 23 Jun 2026 19:47:40 -0400 Subject: [PATCH] import_viewer: fix counter upsert race + add explicit slot field Pre-materialize ViewerEventCounters before the mission loop so in-flight Adds are visible to subsequent iterations sharing the same (EventKey, Period); add optional ImportMission.Slot field so callers can supply weekly slot identity (2 or 3) rather than relying on the LotType-derived default of 1. Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/AdminController.cs | 48 +++++++--- .../Requests/Admin/ImportViewerRequest.cs | 3 + .../Controllers/AdminControllerTests.cs | 92 +++++++++++++++++++ 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 254e7974..13282750 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -279,6 +279,31 @@ public class AdminController : SVSimController .ToDictionaryAsync(c => c.Id); var nowUtc = DateTimeOffset.UtcNow; + // Resolve counter keys for all missions up-front so we can load existing counters + // in one query and avoid duplicate-Add races when two missions share the same + // (EventKey, Period) within the same import batch. + var resolvedCounterKeys = new Dictionary(); + foreach (var m in missions) + { + if (!catalogs.TryGetValue(m.MissionId, out var cat)) continue; + var r = ResolveMissionCounter(cat, nowUtc); + if (r is not null) + resolvedCounterKeys[m.MissionId] = r.Value; + } + + // Collect all distinct (EventKey, Period) pairs we will touch. + var allEventKeys = resolvedCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList(); + var allPeriods = resolvedCounterKeys.Values.Select(v => v.Period).Distinct().ToList(); + + // Load existing counters in one query (widens slightly; filter in memory below — fine for small N). + var existingCounters = await _dbContext.ViewerEventCounters + .Where(c => c.ViewerId == viewer.Id + && allEventKeys.Contains(c.EventKey) + && allPeriods.Contains(c.Period)) + .ToListAsync(); + var counterCache = existingCounters + .ToDictionary(c => (c.EventKey, c.Period)); + foreach (var m in missions) { if (!catalogs.TryGetValue(m.MissionId, out var cat)) @@ -289,35 +314,30 @@ public class AdminController : SVSimController viewer.Missions.Add(new ViewerMission { MissionCatalogId = cat.Id, - Slot = cat.LotType == 6 ? 0 : 1, + Slot = m.Slot ?? (cat.LotType == 6 ? 0 : 1), MissionStatus = m.MissionStatus, AssignedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ClaimedAt = null }); - var resolved = ResolveMissionCounter(cat, nowUtc); - if (resolved is null) + if (!resolvedCounterKeys.TryGetValue(m.MissionId, out var resolved)) { skippedMissionCounterIds.Add(m.MissionId); continue; } - var (eventKey, period) = resolved.Value; - var existingCounter = await _dbContext.ViewerEventCounters - .FirstOrDefaultAsync(c => c.ViewerId == viewer.Id && c.EventKey == eventKey && c.Period == period); - if (existingCounter is null) + var (eventKey, period) = resolved; + if (!counterCache.TryGetValue((eventKey, period), out var counter)) { - _dbContext.ViewerEventCounters.Add(new ViewerEventCounter + counter = new ViewerEventCounter { ViewerId = viewer.Id, EventKey = eventKey, Period = period, - Count = m.TotalCount - }); - } - else - { - existingCounter.Count = m.TotalCount; + }; + _dbContext.ViewerEventCounters.Add(counter); + counterCache[(eventKey, period)] = counter; } + counter.Count = m.TotalCount; } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs index 3f3a2bfa..d8a027b2 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs @@ -93,6 +93,9 @@ public class ImportMission [JsonPropertyName("total_count")] public int TotalCount { get; set; } + + [JsonPropertyName("slot")] + public int? Slot { get; set; } } public class ImportMissionMeta diff --git a/SVSim.UnitTests/Controllers/AdminControllerTests.cs b/SVSim.UnitTests/Controllers/AdminControllerTests.cs index 8476344a..04d412a5 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -585,4 +585,96 @@ public class AdminControllerTests Assert.That(verifyDb3.ViewerMissions.Count(m => m.MissionCatalogId == 9002), Is.EqualTo(1)); Assert.That(verifyDb3.ViewerEventCounters.Any(), Is.False); } + + [Test] + public async Task ImportViewer_Missions_TwoMissionsSharingEventType_SingleCounterUpsert() + { + // Two weekly missions (LotType=2) share the same EventType — same (EventKey, WeekKey) + // period — but occupy different slots (1 and 2 via explicit slot override) so the slot + // unique-index is satisfied. Without the pre-materialized counterCache the second DB read + // won't see the first mission's pending Add → duplicate insert / unique-constraint failure. + // With the fix both missions upsert the SAME counter, resulting in exactly one row. + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var seedDb = seedScope.ServiceProvider.GetRequiredService(); + seedDb.MissionCatalog.AddRange( + new MissionCatalogEntry { Id = 9003, LotType = 2, EventType = "battle_win_total", EventArg = null }, + new MissionCatalogEntry { Id = 9004, LotType = 2, EventType = "battle_win_total", EventArg = null }); + await seedDb.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000005UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Missions = new List + { + new() { MissionId = 9003, MissionStatus = 1, TotalCount = 10, Slot = 1 }, + new() { MissionId = 9004, MissionStatus = 1, TotalCount = 10, Slot = 2 }, + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedMissionCount, Is.EqualTo(0)); + Assert.That(body.SkippedMissionCounterCount, Is.EqualTo(0)); + + using var verifyScope = factory.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var viewerId = await verifyDb.Viewers + .Where(v => v.SocialAccountConnections + .Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId)) + .Select(v => v.Id).SingleAsync(); + + // Must be exactly ONE counter row for (EventKey="battle_win_total"), not two. + Assert.That( + verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId && c.EventKey == "battle_win_total"), + Is.EqualTo(1)); + Assert.That( + verifyDb.ViewerEventCounters + .Single(c => c.ViewerId == viewerId && c.EventKey == "battle_win_total").Count, + Is.EqualTo(10)); + } + + [Test] + public async Task ImportViewer_Missions_ExplicitSlotRoundTrips() + { + // A weekly catalog mission (LotType=2) posted with an explicit slot=3 must persist Slot=3, + // not fall back to the LotType-derived default of 1. + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var seedDb = seedScope.ServiceProvider.GetRequiredService(); + seedDb.MissionCatalog.Add(new MissionCatalogEntry + { + Id = 9005, LotType = 2 /* weekly */, + EventType = null, EventArg = null + }); + await seedDb.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000006UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Missions = new List + { + new() { MissionId = 9005, MissionStatus = 1, TotalCount = 0, Slot = 3 } + } + }); + resp.EnsureSuccessStatusCode(); + + using var verifyScope = factory.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var viewerId = await verifyDb.Viewers + .Where(v => v.SocialAccountConnections + .Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId)) + .Select(v => v.Id).SingleAsync(); + + var mission = await verifyDb.ViewerMissions + .SingleAsync(m => m.ViewerId == viewerId && m.MissionCatalogId == 9005); + Assert.That(mission.Slot, Is.EqualTo(3)); + } }