import_viewer: clean orphan ViewerEventCounter rows on shrinking re-import

When missions/achievements shrink between re-imports, Pass B and Pass C
now compute the set of (EventKey, Period) counter keys from OLD rows
(before Clear()) and delete any that are not referenced by the new
payload. Prevents stale counters accumulating across import cycles.

TDD: two new failing tests written first (RED), then the fix (GREEN).
All 1383 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-23 20:37:11 -04:00
parent 79e936dcf2
commit 3a52115551
2 changed files with 204 additions and 0 deletions

View File

@@ -274,6 +274,19 @@ public class AdminController : SVSimController
var skippedMissionCounterIds = new HashSet<int>();
if (request.Missions is { } missions)
{
// Snapshot the OLD mission catalog ids BEFORE clearing, then load their catalog
// entries so we can compute which (EventKey, Period) counter keys are going away.
var oldMissionCatalogIds = viewer.Missions.Select(m => m.MissionCatalogId).Distinct().ToList();
var nowUtcForOld = DateTimeOffset.UtcNow;
var oldCatalogs = await _dbContext.MissionCatalog
.Where(c => oldMissionCatalogIds.Contains(c.Id))
.ToListAsync();
var oldMissionCounterKeys = oldCatalogs
.Select(c => ResolveMissionCounter(c, nowUtcForOld))
.Where(r => r is not null)
.Select(r => r!.Value)
.ToHashSet();
viewer.Missions.Clear();
var missionIds = missions.Select(m => m.MissionId).Distinct().ToList();
var catalogs = await _dbContext.MissionCatalog
@@ -293,6 +306,28 @@ public class AdminController : SVSimController
resolvedCounterKeys[m.MissionId] = r.Value;
}
// Delete orphan ViewerEventCounter rows: keys that existed for OLD missions but are
// NOT referenced by any mission in the new payload.
var newMissionCounterKeys = resolvedCounterKeys.Values.ToHashSet();
var orphanMissionKeys = oldMissionCounterKeys
.Where(k => !newMissionCounterKeys.Contains(k))
.ToList();
if (orphanMissionKeys.Count > 0)
{
var orphanEventKeys = orphanMissionKeys.Select(k => k.EventKey).Distinct().ToList();
var orphanPeriods = orphanMissionKeys.Select(k => k.Period).Distinct().ToList();
var orphanCounters = await _dbContext.ViewerEventCounters
.Where(c => c.ViewerId == viewer.Id
&& orphanEventKeys.Contains(c.EventKey)
&& orphanPeriods.Contains(c.Period))
.ToListAsync();
// Filter in-memory to the exact (EventKey, Period) pairs (the DB query widens slightly).
var toDelete = orphanCounters
.Where(c => orphanMissionKeys.Contains((c.EventKey, c.Period)))
.ToList();
_dbContext.ViewerEventCounters.RemoveRange(toDelete);
}
// 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();
@@ -348,6 +383,19 @@ public class AdminController : SVSimController
var skippedAchievementCounterTypes = new HashSet<int>();
if (request.Achievements is { } achievements)
{
// Snapshot the OLD achievement types BEFORE clearing, then load their catalog entries
// so we can compute which (EventKey, Period) counter keys are going away.
var oldAchievementTypes = viewer.Achievements.Select(a => a.AchievementType).Distinct().ToList();
var oldAchCatalogs = await _dbContext.AchievementCatalog
.Where(c => oldAchievementTypes.Contains(c.AchievementType))
.GroupBy(c => c.AchievementType)
.ToDictionaryAsync(g => g.Key, g => g.First());
var oldAchievementCounterKeys = oldAchCatalogs.Values
.Select(c => ResolveAchievementCounter(c))
.Where(r => r is not null)
.Select(r => r!.Value)
.ToHashSet();
viewer.Achievements.Clear();
var types = achievements.Select(a => a.AchievementType).Distinct().ToList();
var catalogs = await _dbContext.AchievementCatalog
@@ -367,6 +415,28 @@ public class AdminController : SVSimController
resolvedAchievementCounterKeys[a.AchievementType] = r.Value;
}
// Delete orphan ViewerEventCounter rows: keys that existed for OLD achievements but
// are NOT referenced by any achievement in the new payload.
var newAchievementCounterKeys = resolvedAchievementCounterKeys.Values.ToHashSet();
var orphanAchKeys = oldAchievementCounterKeys
.Where(k => !newAchievementCounterKeys.Contains(k))
.ToList();
if (orphanAchKeys.Count > 0)
{
var orphanAchEventKeys = orphanAchKeys.Select(k => k.EventKey).Distinct().ToList();
var orphanAchPeriods = orphanAchKeys.Select(k => k.Period).Distinct().ToList();
var orphanAchCounters = await _dbContext.ViewerEventCounters
.Where(c => c.ViewerId == viewer.Id
&& orphanAchEventKeys.Contains(c.EventKey)
&& orphanAchPeriods.Contains(c.Period))
.ToListAsync();
// Filter in-memory to the exact (EventKey, Period) pairs.
var toDeleteAch = orphanAchCounters
.Where(c => orphanAchKeys.Contains((c.EventKey, c.Period)))
.ToList();
_dbContext.ViewerEventCounters.RemoveRange(toDeleteAch);
}
// Collect all distinct (EventKey, Period) pairs we will touch.
var allAchEventKeys = resolvedAchievementCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList();
var allAchPeriods = resolvedAchievementCounterKeys.Values.Select(v => v.Period).Distinct().ToList();