diff --git a/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs b/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs index 5c0aa2e..f2b8eb4 100644 --- a/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs +++ b/SVSim.Database/Repositories/BattlePass/BattlePassRepository.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using SVSim.Database.Models; namespace SVSim.Database.Repositories.BattlePass; @@ -6,14 +7,19 @@ namespace SVSim.Database.Repositories.BattlePass; public sealed class BattlePassRepository : IBattlePassRepository { private readonly SVSimDbContext _db; + private readonly IMemoryCache _cache; - // Process-level cache for the immutable level curve. Bootstrap re-baseline = host restart = cache cleared. - private static IReadOnlyList? _curveCache; - private static readonly SemaphoreSlim _curveCacheLock = new(1, 1); + // Per-host cache for the immutable level curve, scoped via the DI-registered IMemoryCache. + // In production "host == process"; in tests each WebApplicationFactory builds its own + // service provider so the cache is naturally isolated per fixture — avoids the pre-refactor + // race where a process-static cache populated from one test's DbContext served stale data + // to a parallel test reading from a different DB. + private const string LevelCurveCacheKey = "battlepass:level-curve"; - public BattlePassRepository(SVSimDbContext db) + public BattlePassRepository(SVSimDbContext db, IMemoryCache cache) { _db = db; + _cache = cache; } public async Task GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct) @@ -42,25 +48,10 @@ public sealed class BattlePassRepository : IBattlePassRepository public async Task> GetLevelCurveAsync(CancellationToken ct) { - if (_curveCache is not null) return _curveCache; - await _curveCacheLock.WaitAsync(ct); - try - { - if (_curveCache is null) - { - _curveCache = await _db.BattlePassLevels.AsNoTracking() - .OrderBy(e => e.Level) - .ToListAsync(ct); - } - return _curveCache; - } - finally { _curveCacheLock.Release(); } + var cached = await _cache.GetOrCreateAsync(LevelCurveCacheKey, async _ => + (IReadOnlyList)await _db.BattlePassLevels.AsNoTracking() + .OrderBy(e => e.Level) + .ToListAsync(ct)); + return cached!; } - - /// - /// Drops the process-level level-curve cache. Tests that seed BattlePassLevels after the - /// cache has already been populated (by an earlier test's HTTP call) must call this before - /// re-seeding so the next read fetches fresh rows. - /// - internal static void ResetLevelCurveCache() => _curveCache = null; } diff --git a/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs b/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs index 90f5538..4310afd 100644 --- a/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs +++ b/SVSim.Database/Repositories/Mission/MissionCatalogRepository.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using SVSim.Database.Models; namespace SVSim.Database.Repositories.Mission; @@ -6,13 +7,18 @@ namespace SVSim.Database.Repositories.Mission; public sealed class MissionCatalogRepository : IMissionCatalogRepository { private readonly SVSimDbContext _db; + private readonly IMemoryCache _cache; - // Process-level cache for the derived MAX(Level) lookup. Cleared on host restart - // (re-bootstrap is the only legitimate way to mutate the catalog at runtime). - private static IReadOnlyDictionary? _maxLevelCache; - private static readonly SemaphoreSlim _maxLevelLock = new(1, 1); + // Per-host cache for the derived MAX(Level) lookup, scoped via the DI-registered + // IMemoryCache. See BattlePassRepository for the per-host rationale (same parallel-test + // race avoidance — each WebApplicationFactory gets its own cache). + private const string MaxLevelCacheKey = "mission:achievement-max-level-by-type"; - public MissionCatalogRepository(SVSimDbContext db) { _db = db; } + public MissionCatalogRepository(SVSimDbContext db, IMemoryCache cache) + { + _db = db; + _cache = cache; + } public Task> GetByLotTypeAsync(int lotType, CancellationToken ct) => _db.MissionCatalog.AsNoTracking().Where(e => e.LotType == lotType).ToListAsync(ct); @@ -40,21 +46,15 @@ public sealed class MissionCatalogRepository : IMissionCatalogRepository public async Task> GetMaxLevelByAchievementTypeAsync(CancellationToken ct) { - if (_maxLevelCache is not null) return _maxLevelCache; - await _maxLevelLock.WaitAsync(ct); - try + var cached = await _cache.GetOrCreateAsync(MaxLevelCacheKey, async _ => { - if (_maxLevelCache is null) - { - var pairs = await _db.AchievementCatalog.AsNoTracking() - .GroupBy(e => e.AchievementType) - .Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) }) - .ToListAsync(ct); - _maxLevelCache = pairs.ToDictionary(p => p.Type, p => p.Max); - } - return _maxLevelCache; - } - finally { _maxLevelLock.Release(); } + var pairs = await _db.AchievementCatalog.AsNoTracking() + .GroupBy(e => e.AchievementType) + .Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) }) + .ToListAsync(ct); + return (IReadOnlyDictionary)pairs.ToDictionary(p => p.Type, p => p.Max); + }); + return cached!; } public async Task> GetMinLevelByAchievementTypeAsync(CancellationToken ct) diff --git a/SVSim.UnitTests/AssemblyInfo.cs b/SVSim.UnitTests/AssemblyInfo.cs index 0ee0b9f..3d3e55c 100644 --- a/SVSim.UnitTests/AssemblyInfo.cs +++ b/SVSim.UnitTests/AssemblyInfo.cs @@ -1,15 +1,12 @@ using NUnit.Framework; -// Parallelize at the fixture level: different test classes run on separate threads, but tests -// inside one class still execute serially. Each fixture's SVSimTestFactory owns a private -// SQLite :memory: connection, so DB state isn't shared. Process-static caches (e.g. -// BattlePassRepository._curveCache) hold identical data across factories because every test -// seeds from the same JSON, so cross-fixture races on them are data-equivalent. -// Different test classes run on separate threads, but tests inside one class stay serial. Each -// fixture's SVSimTestFactory owns a private SQLite :memory: connection, so DB state isn't shared. -// ParallelScope.All is unsafe today: it exposes process-static caches (BattlePassRepository -// ._curveCache and similar) to races inside heavy-globals fixtures (LoadController, -// PackControllerFullCatalog, StoryService), so within-fixture parallelism is gated on cleaning -// those up first. -[assembly: Parallelizable(ParallelScope.Fixtures)] - +// Tests within a single fixture run concurrently as well as across fixtures. Each +// SVSimTestFactory owns a private SQLite :memory: connection so DB state isn't shared. +// The two previously process-static repo caches (BattlePassRepository._curveCache, +// MissionCatalogRepository._maxLevelCache) now live in the DI-registered IMemoryCache, +// which is per-host — each WebApplicationFactory builds its own service provider so the +// cache is naturally bounded to a single fixture's DB. +// +// Fixtures with shared instance state must opt out via [FixtureLifeCycle(InstancePerTestCase)] +// (see StoryServiceTests) or move state into the test method. +[assembly: Parallelizable(ParallelScope.All)] diff --git a/SVSim.UnitTests/Controllers/LoadControllerTests.cs b/SVSim.UnitTests/Controllers/LoadControllerTests.cs index ee9f4ac..1bef342 100644 --- a/SVSim.UnitTests/Controllers/LoadControllerTests.cs +++ b/SVSim.UnitTests/Controllers/LoadControllerTests.cs @@ -390,9 +390,6 @@ public class LoadControllerTests var db = scope.ServiceProvider.GetRequiredService(); await new SVSim.Bootstrap.Importers.BattlePassImporter().ImportAsync(db, SeedDir); } - // Bust the process-level level-curve cache so the next /load/index call reads the - // freshly-seeded rows rather than a stale empty list from an earlier test's HTTP call. - SVSim.Database.Repositories.BattlePass.BattlePassRepository.ResetLevelCurveCache(); using var client = factory.CreateAuthenticatedClient(viewerId); var response = await client.PostAsync("/load/index", diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index 731a65f..a8a8cc1 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -250,8 +250,6 @@ internal sealed class SVSimTestFactory : WebApplicationFactory await new AvatarAbilityImporter().ImportAsync(ctx, seedDir); await new ArenaSeasonImporter().ImportAsync(ctx, seedDir); await new BattlePassImporter().ImportAsync(ctx, seedDir); - // Reset the process-level level-curve cache so the next HTTP call reads freshly-seeded rows. - BattlePassRepository.ResetLevelCurveCache(); await new BattlePassSeasonImporter().ImportAsync(ctx, seedDir); await new BattlePassRewardImporter().ImportAsync(ctx, seedDir); await new DailyLoginBonusImporter().ImportAsync(ctx, seedDir); diff --git a/SVSim.UnitTests/Services/BattlePassServiceTests.cs b/SVSim.UnitTests/Services/BattlePassServiceTests.cs index da9404d..7e0c294 100644 --- a/SVSim.UnitTests/Services/BattlePassServiceTests.cs +++ b/SVSim.UnitTests/Services/BattlePassServiceTests.cs @@ -18,8 +18,6 @@ public class BattlePassServiceTests private static async Task SeedViewerAndSeason23(SVSimTestFactory f, bool isPremium = false) { long viewerId = await f.SeedViewerAsync(); - // Reset process-level level-curve cache so this test's seeded rows are visible. - BattlePassRepository.ResetLevelCurveCache(); using var scope = f.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // Zero out rupees so post-state totals in reward assertions equal the delta amounts.