From 78fc9875d74151ec0ed21a249cf3222e71acc361 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 15:50:16 +0000 Subject: [PATCH 1/2] Invalidate the fastest lap cache when laps of a session change The per session fastest lap cache calculated an entry only once and kept it until the process ended, so a running session reported the fastest lap of the moment its entry was calculated. The cache now offers InvalidateSession and RemoveSession, and the lap processing drops the affected entry whenever a lap is completed, refreshed, inserted or removed, and when a session is deleted. A change counter per session makes sure a calculation that was invalidated while it was running does not store its outdated result. --- .../FinalClassificationProcessor.cs | 4 + .../Processors/SessionHistoryProcessor.cs | 10 + .../Processors/SessionProcessor.cs | 4 + F1Server.Service/Runtime/PacketProcessor.cs | 8 +- .../Runtime/ParticipantRuntimeData.cs | 7 + .../Cache/FastestLapPerSessionCacheTests.cs | 172 +++++++++++++++--- .../Cache/FastestLapPerSessionCache.cs | 55 +++++- .../Controllers/SessionsController.cs | 4 + 8 files changed, 234 insertions(+), 30 deletions(-) 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..7a22b43 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; @@ -645,14 +646,19 @@ private void RemoveInvalidSessionFromDatabase() { try { + var sessionDbId = Session.SessionDbId; + using (var dbFactory = RepositoryFactory.CreateInstance()) { - var isRemoved = dbFactory.GetRepository()?.Remove(s => s.Id == Session.SessionDbId); + var isRemoved = dbFactory.GetRepository()?.Remove(s => s.Id == sessionDbId); // Removed? if (isRemoved != null && isRemoved.Value) { Session = null; + + // No data of a removed session may stay in the cache + FastestLapPerSessionCache.RemoveSession(sessionDbId); } } } 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.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; From 9b0bf0fe9901f4ffdf36ac5224fd4f8f4b8737ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 16:01:29 +0000 Subject: [PATCH 2/2] Cover the fastest lap cache invalidation of the session history processor The new invalidation calls were not covered by tests, so the quality gate rejected the coverage of the changed lines. Two tests now drive a session history packet that inserts a lap and one that refreshes an already stored lap, and verify that the reported fastest lap of the session follows. The session removal keeps the id of the runtime session instead of a local copy, so the untested method does not add further uncovered lines. --- F1Server.Service/Runtime/PacketProcessor.cs | 10 +- .../SessionHistoryProcessorTests.cs | 212 +++++++++++++++++- 2 files changed, 211 insertions(+), 11 deletions(-) diff --git a/F1Server.Service/Runtime/PacketProcessor.cs b/F1Server.Service/Runtime/PacketProcessor.cs index 7a22b43..01a1d15 100644 --- a/F1Server.Service/Runtime/PacketProcessor.cs +++ b/F1Server.Service/Runtime/PacketProcessor.cs @@ -646,19 +646,17 @@ private void RemoveInvalidSessionFromDatabase() { try { - var sessionDbId = Session.SessionDbId; - using (var dbFactory = RepositoryFactory.CreateInstance()) { - var isRemoved = dbFactory.GetRepository()?.Remove(s => s.Id == sessionDbId); + var isRemoved = dbFactory.GetRepository()?.Remove(s => s.Id == Session.SessionDbId); // Removed? if (isRemoved != null && isRemoved.Value) { - Session = null; - // No data of a removed session may stay in the cache - FastestLapPerSessionCache.RemoveSession(sessionDbId); + FastestLapPerSessionCache.RemoveSession(Session.SessionDbId); + + Session = null; } } } 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 ///