diff --git a/F1Server.Service/Processors/FinalClassificationProcessor.cs b/F1Server.Service/Processors/FinalClassificationProcessor.cs index 4ecb584..5ddc5ba 100644 --- a/F1Server.Service/Processors/FinalClassificationProcessor.cs +++ b/F1Server.Service/Processors/FinalClassificationProcessor.cs @@ -9,6 +9,7 @@ using F1Server.Db.Entity.Repositories; using F1Server.Db.Entity.Tables; using F1Server.Service.Runtime; +using F1Server.WebApi.Cache; using Microsoft.Extensions.Logging; @@ -77,6 +78,9 @@ private bool ProcessFinalClassificationPacket(SessionRuntimeData sessionRuntimeD dbFactory.GetRepository()?.RemoveBySessionId(sessionRuntimeData.SessionDbId, true); dbFactory.GetRepository()?.RemoveWhere(l => l.SessionId == sessionRuntimeData.SessionDbId && l.DbIsInvalidLapTime == 1); + + // Removed laps can change the fastest lap of the session + FastestLapPerSessionCache.InvalidateSession(sessionRuntimeData.SessionDbId); } return isProcessed; diff --git a/F1Server.Service/Processors/SessionHistoryProcessor.cs b/F1Server.Service/Processors/SessionHistoryProcessor.cs index a2e3e45..6f1a1d9 100644 --- a/F1Server.Service/Processors/SessionHistoryProcessor.cs +++ b/F1Server.Service/Processors/SessionHistoryProcessor.cs @@ -11,6 +11,7 @@ using F1Server.Db.Entity.Tables; using F1Server.Service.Cache; using F1Server.Service.Runtime; +using F1Server.WebApi.Cache; using Microsoft.Extensions.Logging; @@ -221,6 +222,9 @@ private void UpdateFinishedLap(ILapHistoryDataBase lapData, ushort lapNumber, Se lapDbData.IsInvalidLapTime = isInvalidLapTime; LapRepositoryCache.AddOrUpdate(lapDbData); + + // The changed lap times can change the fastest lap of the session + FastestLapPerSessionCache.InvalidateSession(lapDbData.SessionId); } } else @@ -296,6 +300,9 @@ private void RefreshStoredLap(LapEntity lapDbData, ILapHistoryDataBase lapData, }); LapRepositoryCache.AddOrUpdate(lapDbData); + + // The refreshed lap is completed now and can be the new fastest lap of the session + FastestLapPerSessionCache.InvalidateSession(lapDbData.SessionId); } /// @@ -344,6 +351,9 @@ private void RefreshStoredLap(LapEntity lapDbData, ILapHistoryDataBase lapData, else { LapRepositoryCache.AddOrUpdate(lapDbData); + + // The new lap is completed and can be the new fastest lap of the session + FastestLapPerSessionCache.InvalidateSession(lapDbData.SessionId); } return lapDbData; diff --git a/F1Server.Service/Processors/SessionProcessor.cs b/F1Server.Service/Processors/SessionProcessor.cs index 63aada9..9a7c228 100644 --- a/F1Server.Service/Processors/SessionProcessor.cs +++ b/F1Server.Service/Processors/SessionProcessor.cs @@ -11,6 +11,7 @@ using F1Server.Db.Entity.Tables; using F1Server.Service.Cache; using F1Server.Service.Runtime; +using F1Server.WebApi.Cache; using Microsoft.Extensions.Logging; @@ -482,6 +483,9 @@ private void ClearPreviousSessionData(long sessionId, RepositoryFactory dbFactor // Remove laps dbFactory.GetRepository()?.RemoveWhere(l => l.SessionId == sessionId); + + // The reused session starts without laps, so its cached fastest lap is no longer valid + FastestLapPerSessionCache.InvalidateSession(sessionId); } catch (Exception ex) { diff --git a/F1Server.Service/Runtime/PacketProcessor.cs b/F1Server.Service/Runtime/PacketProcessor.cs index 9646205..01a1d15 100644 --- a/F1Server.Service/Runtime/PacketProcessor.cs +++ b/F1Server.Service/Runtime/PacketProcessor.cs @@ -15,6 +15,7 @@ using F1Server.Db.Entity.Repositories; using F1Server.Db.Entity.Tables; using F1Server.Service.Processors; +using F1Server.WebApi.Cache; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -652,6 +653,9 @@ private void RemoveInvalidSessionFromDatabase() // Removed? if (isRemoved != null && isRemoved.Value) { + // No data of a removed session may stay in the cache + FastestLapPerSessionCache.RemoveSession(Session.SessionDbId); + Session = null; } } diff --git a/F1Server.Service/Runtime/ParticipantRuntimeData.cs b/F1Server.Service/Runtime/ParticipantRuntimeData.cs index 39b0b03..8b71b55 100644 --- a/F1Server.Service/Runtime/ParticipantRuntimeData.cs +++ b/F1Server.Service/Runtime/ParticipantRuntimeData.cs @@ -6,6 +6,7 @@ using F1Server.Db.Entity.Repositories; using F1Server.Db.Entity.Tables; using F1Server.Service.Cache; +using F1Server.WebApi.Cache; namespace F1Server.Service.Runtime; @@ -336,6 +337,12 @@ public bool CompleteLap(int lapNumber) }, obj => LapRepositoryCache.AddOrUpdate(obj)) ?? false; } + + if (isFinished) + { + // The completed lap can be the new fastest lap of the session + FastestLapPerSessionCache.InvalidateSession(lapEntity.SessionId); + } } return isFinished; diff --git a/F1Server.Tests/Cache/FastestLapPerSessionCacheTests.cs b/F1Server.Tests/Cache/FastestLapPerSessionCacheTests.cs index f515006..5e013e2 100644 --- a/F1Server.Tests/Cache/FastestLapPerSessionCacheTests.cs +++ b/F1Server.Tests/Cache/FastestLapPerSessionCacheTests.cs @@ -32,11 +32,21 @@ public class FastestLapPerSessionCacheTests /// private const uint FastestLapTime = 80000U; + /// + /// Lap time in milliseconds of a lap that is added after the fastest lap of a session was already cached + /// + private const uint FasterLapTime = 78500U; + /// /// Reference lap time in milliseconds of the track created for the tests in this class /// private const uint ReferenceLapTime = 79000U; + /// + /// Format of a lap time + /// + private const string LapTimeLiteral = @"mm\:ss\.fff"; + /// /// Name of the driver created for the tests in this class /// @@ -56,6 +66,31 @@ public class FastestLapPerSessionCacheTests /// private static readonly List _testSessionIds = []; + /// + /// Database id of the track created for the tests in this class + /// + private static long _testTrackId; + + /// + /// Database id of the driver created for the tests in this class + /// + private static long _testDriverId; + + /// + /// Database id of the nationality created for the tests in this class + /// + private static long _testNationalityId; + + /// + /// Database id of the team created for the tests in this class + /// + private static long _testTeamId; + + /// + /// Game session code of the last session created for the tests in this class + /// + private static ulong _lastSessionCode = 198000000000UL; + #endregion // Fields #region Properties @@ -119,36 +154,19 @@ public static async Task ClassInit(TestContext context) Assert.IsTrue(dbFactory.GetRepository()?.Add(trackEntity), "Track entity could not be added to the database!"); + _testTrackId = trackEntity.Id; + _testDriverId = driverEntity.Id; + _testNationalityId = nationalityEntity.Id; + _testTeamId = teamEntity.Id; + for (var sessionIndex = 0; sessionIndex < TestSessionCount; sessionIndex++) { - var sessionEntity = new SessionEntity - { - SessionId = 198000000000UL + (ulong)sessionIndex, - CreationTimestamp = DateTime.UtcNow, - SessionType = SessionType.Qualifying1, - TrackId = trackEntity.Id, - GameVersionId = 1 - }; - - Assert.IsTrue(dbFactory.GetRepository()?.Add(sessionEntity), "Session entity could not be added to the database!"); - - var participantEntity = new ParticipantEntity - { - SessionId = sessionEntity.Id, - DriverId = driverEntity.Id, - NationalityId = nationalityEntity.Id, - TeamId = teamEntity.Id, - DriverName = TestDriverName, - ArrayIndex = 1, - IsHumanControlled = true - }; - - Assert.IsTrue(dbFactory.GetRepository()?.Add(participantEntity), "Participant entity could not be added to the database!"); + var sessionId = AddSession(dbFactory, out var participantId); - AddLap(dbFactory, sessionEntity.Id, participantEntity.Id, 1, FastestLapTime); - AddLap(dbFactory, sessionEntity.Id, participantEntity.Id, 2, FastestLapTime + 1500U); + AddLap(dbFactory, sessionId, participantId, 1, FastestLapTime); + AddLap(dbFactory, sessionId, participantId, 2, FastestLapTime + 1500U); - _testSessionIds.Add(sessionEntity.Id); + _testSessionIds.Add(sessionId); } } } @@ -286,10 +304,112 @@ public async Task FastestLapPerSessionCacheCancelledWarmUpDoesNotMarkCacheAsInit } } + /// + /// Verifies that a lap added to an already cached session is reported after the session was invalidated, so a + /// running session does not keep the fastest lap of the moment its entry was calculated + /// + /// A task that represents the asynchronous operation + [TestMethod] + public async Task FastestLapPerSessionCacheInvalidateSessionReturnsNewFastestLap() + { + long sessionId; + long participantId; + + using (var dbFactory = RepositoryFactory.CreateInstance()) + { + sessionId = AddSession(dbFactory, out participantId); + + AddLap(dbFactory, sessionId, participantId, 1, FastestLapTime); + } + + var cachedLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionId).ConfigureAwait(false); + + Assert.AreEqual(TimeSpan.FromMilliseconds(FastestLapTime).ToString(LapTimeLiteral), cachedLapData.FastestLap, "The only lap of the session should be reported as fastest lap!"); + + using (var dbFactory = RepositoryFactory.CreateInstance()) + { + AddLap(dbFactory, sessionId, participantId, 2, FasterLapTime); + } + + var staleLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionId).ConfigureAwait(false); + + Assert.AreSame(cachedLapData, staleLapData, "Without an invalidation the already cached data of the session should be returned!"); + + FastestLapPerSessionCache.InvalidateSession(sessionId); + + var updatedLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionId).ConfigureAwait(false); + + Assert.AreEqual(TimeSpan.FromMilliseconds(FasterLapTime).ToString(LapTimeLiteral), updatedLapData.FastestLap, "After an invalidation the faster lap added to the session should be reported as fastest lap!"); + } + + /// + /// Verifies that a removed session is calculated again instead of returning the data of the deleted session + /// + /// A task that represents the asynchronous operation + [TestMethod] + public async Task FastestLapPerSessionCacheRemoveSessionDropsCachedData() + { + long sessionId; + + using (var dbFactory = RepositoryFactory.CreateInstance()) + { + sessionId = AddSession(dbFactory, out var participantId); + + AddLap(dbFactory, sessionId, participantId, 1, FastestLapTime); + } + + var cachedLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionId).ConfigureAwait(false); + + FastestLapPerSessionCache.RemoveSession(sessionId); + + var reloadedLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionId).ConfigureAwait(false); + + Assert.AreNotSame(cachedLapData, reloadedLapData, "A removed session must not be served from the cache anymore!"); + } + #endregion // Methods #region Private methods + /// + /// Adds a session with one participant to the test database + /// + /// Repository factory used to store the session + /// Database id of the participant created for the session + /// Database id of the created session + private static long AddSession(RepositoryFactory dbFactory, out long participantId) + { + _lastSessionCode += 1UL; + + var sessionEntity = new SessionEntity + { + SessionId = _lastSessionCode, + CreationTimestamp = DateTime.UtcNow, + SessionType = SessionType.Qualifying1, + TrackId = _testTrackId, + GameVersionId = 1 + }; + + Assert.IsTrue(dbFactory.GetRepository()?.Add(sessionEntity), "Session entity could not be added to the database!"); + + var participantEntity = new ParticipantEntity + { + SessionId = sessionEntity.Id, + DriverId = _testDriverId, + NationalityId = _testNationalityId, + TeamId = _testTeamId, + DriverName = TestDriverName, + ArrayIndex = 1, + IsHumanControlled = true + }; + + Assert.IsTrue(dbFactory.GetRepository()?.Add(participantEntity), "Participant entity could not be added to the database!"); + + participantId = participantEntity.Id; + + return sessionEntity.Id; + } + /// /// Adds a valid completed lap to the test database /// diff --git a/F1Server.Tests/Processors/SessionHistoryProcessorTests.cs b/F1Server.Tests/Processors/SessionHistoryProcessorTests.cs index 503fb3f..fe227f9 100644 --- a/F1Server.Tests/Processors/SessionHistoryProcessorTests.cs +++ b/F1Server.Tests/Processors/SessionHistoryProcessorTests.cs @@ -9,6 +9,7 @@ using F1Server.Service.Processors; using F1Server.Service.Runtime; using F1Server.Tests.Data; +using F1Server.WebApi.Cache; namespace F1Server.Tests.Processors; @@ -50,11 +51,51 @@ public class SessionHistoryProcessorTests /// private const ushort TestCarIndex3 = 9; + /// + /// Unique game session id used by the inserted lap test + /// + private const ulong TestSessionUniqueId4 = 419419419422UL; + + /// + /// Car index of the test participant of the inserted lap test + /// + private const ushort TestCarIndex4 = 10; + + /// + /// Unique game session id used by the refreshed lap test + /// + private const ulong TestSessionUniqueId5 = 419419419423UL; + + /// + /// Car index of the test participant of the refreshed lap test + /// + private const ushort TestCarIndex5 = 11; + /// /// Sector 3 sentinel time written directly to the database to detect an unwanted second update /// private const uint SentinelSector3Time = 12345; + /// + /// Lap time in milliseconds reported by the session history packet of the fastest lap cache tests + /// + private const uint HistoryLapTime = 90000; + + /// + /// Lap time in milliseconds of the lap that is already stored before the session history packet is processed + /// + private const uint StoredLapTime = 92000; + + /// + /// Reference lap time in milliseconds of the tracks created for the tests in this class + /// + private const uint ReferenceLapTime = 90000; + + /// + /// Format of a lap time + /// + private const string LapTimeLiteral = @"mm\:ss\.fff"; + #endregion // Constants #region Test methods @@ -66,7 +107,7 @@ public class SessionHistoryProcessorTests [TestMethod] public void SessionHistoryProcessorCompletedLapIsNotDuplicated() { - var (sessionDbId, participantDbId) = CreateTestEntities(419001, 419, 419002, TestSessionUniqueId, TestCarIndex); + var (sessionDbId, participantDbId) = CreateTestEntities(419001, 419, 419002, TestSessionUniqueId, TestCarIndex, 191); var sessionRuntimeData = new SessionRuntimeData(2025, TestSessionUniqueId, SessionType.Race) { @@ -165,7 +206,7 @@ public void SessionHistoryProcessorCompletedLapIsNotDuplicated() [TestMethod] public void SessionHistoryProcessorUnfinishedLapsAreCompletedAndInvalidated() { - var (sessionDbId, participantDbId) = CreateTestEntities(419003, 4191, 419004, TestSessionUniqueId2, TestCarIndex2); + var (sessionDbId, participantDbId) = CreateTestEntities(419003, 4191, 419004, TestSessionUniqueId2, TestCarIndex2, 192); var sessionRuntimeData = new SessionRuntimeData(2025, TestSessionUniqueId2, SessionType.Race) { @@ -272,7 +313,7 @@ public void SessionHistoryProcessorUnfinishedLapsAreCompletedAndInvalidated() [TestMethod] public void SessionHistoryProcessorChangedLapValuesAreUpdatedExactlyOnce() { - var (sessionDbId, participantDbId) = CreateTestEntities(419005, 4192, 419006, TestSessionUniqueId3, TestCarIndex3); + var (sessionDbId, participantDbId) = CreateTestEntities(419005, 4192, 419006, TestSessionUniqueId3, TestCarIndex3, 193); var sessionRuntimeData = new SessionRuntimeData(2025, TestSessionUniqueId3, SessionType.Race) { @@ -391,6 +432,77 @@ public void SessionHistoryProcessorChangedLapValuesAreUpdatedExactlyOnce() } } + /// + /// A lap inserted by a session history packet invalidates the cached fastest lap of the session, so the new lap is + /// reported instead of the state of the last calculation + /// + /// A task that represents the asynchronous operation + [TestMethod] + public async Task SessionHistoryProcessorInsertedLapInvalidatesFastestLapCache() + { + var (sessionDbId, participantDbId) = CreateTestEntities(419007, 4193, 419008, TestSessionUniqueId4, TestCarIndex4, 194); + + var emptyLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionDbId).ConfigureAwait(false); + + Assert.IsNull(emptyLapData.FastestLap, "A session without laps must not report a fastest lap!"); + + var (sessionRuntimeData, sessionHistoryProcessor, packetHeader) = CreateProcessor(sessionDbId, participantDbId, TestSessionUniqueId4, TestCarIndex4); + + var sessionHistoryData = CreateSessionHistoryData(packetHeader, TestCarIndex4, HistoryLapTime); + + Assert.IsTrue(sessionHistoryProcessor.Process(sessionHistoryData, sessionRuntimeData), "Session history packet not correctly processed!"); + + var insertedLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionDbId).ConfigureAwait(false); + + Assert.AreEqual(TimeSpan.FromMilliseconds(HistoryLapTime).ToString(LapTimeLiteral), insertedLapData.FastestLap, "The lap inserted by the session history packet should be reported as fastest lap!"); + } + + /// + /// A stored lap refreshed by a session history packet invalidates the cached fastest lap of the session, so the + /// new lap time is reported instead of the state of the last calculation + /// + /// A task that represents the asynchronous operation + [TestMethod] + public async Task SessionHistoryProcessorRefreshedLapInvalidatesFastestLapCache() + { + var (sessionDbId, participantDbId) = CreateTestEntities(419009, 4194, 419010, TestSessionUniqueId5, TestCarIndex5, 195); + + // The lap is stored without being cached, so the session history packet refreshes the stored row + using (var dbFactory = RepositoryFactory.CreateInstance()) + { + var lapEntity = new LapEntity + { + LapNumber = 1, + ParticipantId = participantDbId, + SessionId = sessionDbId, + LapTime = StoredLapTime, + Sector1Time = 32000, + Sector2Time = 30000, + Sector3Time = 30000, + IsCompleted = true, + DriverStatus = DriverStatus.OnTrack, + PitStatus = PitStatus.None, + ResultStatus = ResultStatus.Active + }; + + Assert.IsTrue(dbFactory.GetRepository()?.Add(lapEntity), "Lap entity could not be added to the database!"); + } + + var storedLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionDbId).ConfigureAwait(false); + + Assert.AreEqual(TimeSpan.FromMilliseconds(StoredLapTime).ToString(LapTimeLiteral), storedLapData.FastestLap, "The already stored lap should be reported as fastest lap!"); + + var (sessionRuntimeData, sessionHistoryProcessor, packetHeader) = CreateProcessor(sessionDbId, participantDbId, TestSessionUniqueId5, TestCarIndex5); + + var sessionHistoryData = CreateSessionHistoryData(packetHeader, TestCarIndex5, HistoryLapTime); + + Assert.IsTrue(sessionHistoryProcessor.Process(sessionHistoryData, sessionRuntimeData), "Session history packet not correctly processed!"); + + var refreshedLapData = await FastestLapPerSessionCache.GetFastestLapDataForSessionAsync(sessionDbId).ConfigureAwait(false); + + Assert.AreEqual(TimeSpan.FromMilliseconds(HistoryLapTime).ToString(LapTimeLiteral), refreshedLapData.FastestLap, "The lap time refreshed by the session history packet should be reported as fastest lap!"); + } + #endregion // Test methods #region Methods @@ -403,8 +515,9 @@ public void SessionHistoryProcessorChangedLapValuesAreUpdatedExactlyOnce() /// Game id of the team /// Unique game session id /// Car index of the participant in the game packet arrays + /// Game id of the track of the session /// Tuple with the database ids of the created session and participant - private static (long SessionDbId, long ParticipantDbId) CreateTestEntities(int driverGameId, ushort nationalityGameId, int teamGameId, ulong sessionUniqueId, ushort carIndex) + private static (long SessionDbId, long ParticipantDbId) CreateTestEntities(int driverGameId, ushort nationalityGameId, int teamGameId, ulong sessionUniqueId, ushort carIndex, int trackNumber) { using (var dbFactory = RepositoryFactory.CreateInstance()) { @@ -432,12 +545,25 @@ private static (long SessionDbId, long ParticipantDbId) CreateTestEntities(int d Assert.IsTrue(dbFactory.GetRepository()?.Add(teamEntity), "Team entity could not be added to the database!"); + // The session query includes the track, so the session needs a track that exists in the test database + var trackEntity = new TrackEntity + { + TrackNumber = trackNumber, + Name = "Test Track", + LapReferenceTime = ReferenceLapTime, + Sector1ReferenceTime = 30000, + Sector2ReferenceTime = 30000, + Sector3ReferenceTime = 30000 + }; + + Assert.IsTrue(dbFactory.GetRepository()?.Add(trackEntity), "Track entity could not be added to the database!"); + var sessionEntity = new SessionEntity { SessionId = sessionUniqueId, CreationTimestamp = DateTime.UtcNow, SessionType = SessionType.Race, - TrackId = 1, + TrackId = trackEntity.Id, GameVersionId = 1 }; @@ -459,6 +585,82 @@ private static (long SessionDbId, long ParticipantDbId) CreateTestEntities(int d } } + /// + /// Creates the session runtime data, the participant runtime data and the session history processor of a test + /// + /// Database id of the session + /// Database id of the participant + /// Unique game session id + /// Car index of the participant in the game packet arrays + /// Tuple with the created session runtime data, the processor and the packet header + private static (SessionRuntimeData SessionRuntimeData, SessionHistoryProcessor Processor, PacketHeader PacketHeader) CreateProcessor(long sessionDbId, long participantDbId, ulong sessionUniqueId, ushort carIndex) + { + var sessionRuntimeData = new SessionRuntimeData(2025, sessionUniqueId, SessionType.Race) + { + HasParticipants = true, + IsRecordable = true, + CurrentSession = new LiveSessionData + { + DbId = sessionDbId, + SessionGameId = sessionUniqueId, + SessionType = SessionType.Race + } + }; + + var participantRuntimeData = new ParticipantRuntimeData(sessionRuntimeData) + { + IsValidObject = true, + ParticipantDbId = participantDbId, + ArrayIndex = carIndex + }; + + Assert.IsTrue(sessionRuntimeData.Participants.TryAdd(carIndex, participantRuntimeData), "Participant runtime data could not be registered!"); + + var packetHeader = new PacketHeader + { + GameVersion = 2025, + PacketType = PacketTypes.SessionHistory, + UniqueSessionId = sessionUniqueId, + PlayerCarIndex = carIndex + }; + + var sessionHistoryProcessor = new SessionHistoryProcessor(TestData.ServiceProvider, + packetHeader, + new LiveGameData + { + GameVersion = 2025 + }); + + return (sessionRuntimeData, sessionHistoryProcessor, packetHeader); + } + + /// + /// Creates a session history packet reporting a single completed lap + /// + /// Header of the packet + /// Car index of the participant in the game packet arrays + /// Lap time in milliseconds of the reported lap + /// Session history packet data + private static SessionHistoryData CreateSessionHistoryData(PacketHeader packetHeader, ushort carIndex, uint lapTime) + { + var sessionHistory = new SessionHistoryData2025 + { + CarIndex = carIndex, + NumberOfLaps = 1 + }; + + sessionHistory.LapHistory[0] = new SessionHistoryLapData2025 + { + LapTime = lapTime, + Sector1Time = (ushort)(lapTime / 3U), + Sector2Time = (ushort)(lapTime / 3U), + Sector3Time = (ushort)(lapTime - (2U * (lapTime / 3U))), + LapValidFlag = 0x0F + }; + + return new SessionHistoryData(packetHeader, sessionHistory); + } + /// /// Asserts that exactly one lap row exists for the test participant and lap number 1 /// diff --git a/F1Server.WebApi/Cache/FastestLapPerSessionCache.cs b/F1Server.WebApi/Cache/FastestLapPerSessionCache.cs index 9e41be5..4230007 100644 --- a/F1Server.WebApi/Cache/FastestLapPerSessionCache.cs +++ b/F1Server.WebApi/Cache/FastestLapPerSessionCache.cs @@ -13,7 +13,7 @@ namespace F1Server.WebApi.Cache; /// /// Provides a caching mechanism for storing and retrieving the fastest lap data for racing sessions /// -internal static class FastestLapPerSessionCache +public static class FastestLapPerSessionCache { #region Constants @@ -33,6 +33,12 @@ internal static class FastestLapPerSessionCache /// private static readonly ConcurrentDictionary _sessionLocks = new(); + /// + /// Change counter per session id, incremented by every invalidation so a calculation that started before the + /// invalidation does not store its outdated result + /// + private static readonly ConcurrentDictionary _sessionVersions = new(); + /// /// Gates the initial cache warm up so it only runs once /// @@ -83,6 +89,44 @@ public static async Task GetFastestLapDataForSessionA }; } + /// + /// Drops the cached fastest lap data of a session, so the next request calculates it again. Has to be called + /// whenever laps of the session were added, changed or removed - otherwise a running session keeps reporting the + /// fastest lap of the moment its entry was calculated + /// + /// The unique identifier of the session whose cached data is no longer up to date + public static void InvalidateSession(long sessionId) + { + // A calculation that is currently running for this session sees the new version and discards its result + _sessionVersions.AddOrUpdate(sessionId, 1L, (_, version) => version + 1L); + + _fastestLapCache.TryRemove(sessionId, out _); + } + + /// + /// Removes a session from the cache completely, including its calculation gate. Has to be called when the session + /// itself was deleted, so no data of a no longer existing session is kept + /// + /// The unique identifier of the deleted session + public static void RemoveSession(long sessionId) + { + InvalidateSession(sessionId); + + _sessionLocks.TryRemove(sessionId, out _); + } + + /// + /// Gets the current change counter of a session + /// + /// The unique identifier of the session + /// The change counter of the session, or 0 if the session was never invalidated + private static long GetSessionVersion(long sessionId) + { + return _sessionVersions.TryGetValue(sessionId, out var version) + ? version + : 0L; + } + /// /// Updates the cache with the fastest lap data for the specified session without blocking other sessions /// @@ -106,11 +150,14 @@ public static async Task GetFastestLapDataForSessionA return cachedLapData; } + var versionBeforeCalculation = GetSessionVersion(sessionId); + using (var dbFactory = RepositoryFactory.CreateInstance()) { var fastestLapData = await CalculateFastestLapDataAsync(sessionId, dbFactory, cancellationToken).ConfigureAwait(false); - if (fastestLapData != null) + // An invalidation during the calculation makes the result outdated before it is stored + if (fastestLapData != null && GetSessionVersion(sessionId) == versionBeforeCalculation) { _fastestLapCache[sessionId] = fastestLapData; } @@ -265,9 +312,11 @@ private static async Task EnsureCacheInitialized(CancellationToken cancellationT return; } + var versionBeforeCalculation = GetSessionVersion(sessionId); + var fastestLapData = await CalculateFastestLapDataAsync(sessionId, dbFactory, cancellationToken).ConfigureAwait(false); - if (fastestLapData != null) + if (fastestLapData != null && GetSessionVersion(sessionId) == versionBeforeCalculation) { // Update or add the fastest lap data for the session _fastestLapCache[sessionId] = fastestLapData; diff --git a/F1Server.WebApi/Controllers/SessionsController.cs b/F1Server.WebApi/Controllers/SessionsController.cs index 0c3a948..c77115f 100644 --- a/F1Server.WebApi/Controllers/SessionsController.cs +++ b/F1Server.WebApi/Controllers/SessionsController.cs @@ -5,6 +5,7 @@ using F1Server.Data.ViewData; using F1Server.Db.Entity; using F1Server.Db.Entity.Repositories; +using F1Server.WebApi.Cache; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -613,6 +614,9 @@ public IActionResult DeleteSession(long sessionId, ulong sessionCode) isSessionRemoved = dbFactory.GetRepository() ?.Remove(s => s.Id == session.Id) ?? false; + + // No data of a removed session may stay in the cache + FastestLapPerSessionCache.RemoveSession(session.Id); } isDeleted = isSessionRemoved && isParticipantsRemoved && isLapsRemoved && isTelemetryRemoved;