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 <noreply@anthropic.com>
This commit is contained in:
@@ -279,6 +279,31 @@ public class AdminController : SVSimController
|
|||||||
.ToDictionaryAsync(c => c.Id);
|
.ToDictionaryAsync(c => c.Id);
|
||||||
var nowUtc = DateTimeOffset.UtcNow;
|
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<int, (string EventKey, string Period)>();
|
||||||
|
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)
|
foreach (var m in missions)
|
||||||
{
|
{
|
||||||
if (!catalogs.TryGetValue(m.MissionId, out var cat))
|
if (!catalogs.TryGetValue(m.MissionId, out var cat))
|
||||||
@@ -289,35 +314,30 @@ public class AdminController : SVSimController
|
|||||||
viewer.Missions.Add(new ViewerMission
|
viewer.Missions.Add(new ViewerMission
|
||||||
{
|
{
|
||||||
MissionCatalogId = cat.Id,
|
MissionCatalogId = cat.Id,
|
||||||
Slot = cat.LotType == 6 ? 0 : 1,
|
Slot = m.Slot ?? (cat.LotType == 6 ? 0 : 1),
|
||||||
MissionStatus = m.MissionStatus,
|
MissionStatus = m.MissionStatus,
|
||||||
AssignedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
AssignedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
ClaimedAt = null
|
ClaimedAt = null
|
||||||
});
|
});
|
||||||
|
|
||||||
var resolved = ResolveMissionCounter(cat, nowUtc);
|
if (!resolvedCounterKeys.TryGetValue(m.MissionId, out var resolved))
|
||||||
if (resolved is null)
|
|
||||||
{
|
{
|
||||||
skippedMissionCounterIds.Add(m.MissionId);
|
skippedMissionCounterIds.Add(m.MissionId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var (eventKey, period) = resolved.Value;
|
var (eventKey, period) = resolved;
|
||||||
var existingCounter = await _dbContext.ViewerEventCounters
|
if (!counterCache.TryGetValue((eventKey, period), out var counter))
|
||||||
.FirstOrDefaultAsync(c => c.ViewerId == viewer.Id && c.EventKey == eventKey && c.Period == period);
|
|
||||||
if (existingCounter is null)
|
|
||||||
{
|
{
|
||||||
_dbContext.ViewerEventCounters.Add(new ViewerEventCounter
|
counter = new ViewerEventCounter
|
||||||
{
|
{
|
||||||
ViewerId = viewer.Id,
|
ViewerId = viewer.Id,
|
||||||
EventKey = eventKey,
|
EventKey = eventKey,
|
||||||
Period = period,
|
Period = period,
|
||||||
Count = m.TotalCount
|
};
|
||||||
});
|
_dbContext.ViewerEventCounters.Add(counter);
|
||||||
}
|
counterCache[(eventKey, period)] = counter;
|
||||||
else
|
|
||||||
{
|
|
||||||
existingCounter.Count = m.TotalCount;
|
|
||||||
}
|
}
|
||||||
|
counter.Count = m.TotalCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ public class ImportMission
|
|||||||
|
|
||||||
[JsonPropertyName("total_count")]
|
[JsonPropertyName("total_count")]
|
||||||
public int TotalCount { get; set; }
|
public int TotalCount { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slot")]
|
||||||
|
public int? Slot { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImportMissionMeta
|
public class ImportMissionMeta
|
||||||
|
|||||||
@@ -585,4 +585,96 @@ public class AdminControllerTests
|
|||||||
Assert.That(verifyDb3.ViewerMissions.Count(m => m.MissionCatalogId == 9002), Is.EqualTo(1));
|
Assert.That(verifyDb3.ViewerMissions.Count(m => m.MissionCatalogId == 9002), Is.EqualTo(1));
|
||||||
Assert.That(verifyDb3.ViewerEventCounters.Any(), Is.False);
|
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<SVSimDbContext>();
|
||||||
|
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<ImportMission>
|
||||||
|
{
|
||||||
|
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<ImportViewerResponse>(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<SVSimDbContext>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<ImportMission>
|
||||||
|
{
|
||||||
|
new() { MissionId = 9005, MissionStatus = 1, TotalCount = 0, Slot = 3 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var verifyScope = factory.Services.CreateScope();
|
||||||
|
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user