From e01121fed6bfad91ba36fc7ec93492c3dd755c80 Mon Sep 17 00:00:00 2001 From: STK0Cervanthes Date: Sat, 15 Aug 2026 02:08:27 +0200 Subject: [PATCH 1/3] fix: stop holding pooled DB connection during external leaderboard HTTP retries --- GenOnlineService/ExternalLeaderboardsClient.cs | 18 ++++++++++++++---- GenOnlineService/LobbyManager.cs | 3 ++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/GenOnlineService/ExternalLeaderboardsClient.cs b/GenOnlineService/ExternalLeaderboardsClient.cs index 4f14452..d6008c4 100644 --- a/GenOnlineService/ExternalLeaderboardsClient.cs +++ b/GenOnlineService/ExternalLeaderboardsClient.cs @@ -10,6 +10,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Polly; @@ -122,7 +123,7 @@ private static SocketsHttpHandler CreateLeaderboardsHandler() }; } - public static async Task PostMatchResultAsync(AppDbContext db, Lobby lobby) + public static async Task PostMatchResultAsync(IDbContextFactory dbFactory, Lobby lobby) { if (lobby.MatchID == 0) return; @@ -131,8 +132,14 @@ public static async Task PostMatchResultAsync(AppDbContext db, Lobby lobby) { GetExternalLeaderboardsConfig(out string postUrl, out _, out string postToken, out _); - // Load the match payload - var matchEntry = await Database.MatchHistory.LoadMatchHistoryEntryAsync(db, (long)lobby.MatchID); + // Load the match payload. Context is closed before the HTTP call so the pooled connection + // isn't held open for the (potentially multi-minute, with retries) external request below. + Controllers.MatchHistory_Entry? matchEntry; + await using (var readDb = await dbFactory.CreateDbContextAsync()) + { + matchEntry = await Database.MatchHistory.LoadMatchHistoryEntryAsync(readDb, (long)lobby.MatchID); + } + if (matchEntry == null) { Console.WriteLine($"[WARNING] MatchHistory entry not found for match ID {lobby.MatchID}"); @@ -206,6 +213,9 @@ await retryPolicy.ExecuteAsync(async () => // Only player IDs that were actually part of this match are valid recipients of an ELO update. var expectedPlayerIds = new HashSet(matchEntry.members.Where(m => m.HasValue).Select(m => m.Value.user_id)); + // Fresh, short-lived context just for the persistence writes below. + await using var writeDb = await dbFactory.CreateDbContextAsync(); + foreach (var (userId, updatedPlayer) in refreshResponse.data) { if (!expectedPlayerIds.Contains(userId)) @@ -228,7 +238,7 @@ await retryPolicy.ExecuteAsync(async () => } // Call SaveELOData to persist as fallback - await Database.Users.SaveELOData(db, userId, new EloData(newRating, newMonthlyRating, newMatches)); + await Database.Users.SaveELOData(writeDb, userId, new EloData(newRating, newMonthlyRating, newMatches)); } } catch (Exception ex) diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index e19c6ce..e605b7d 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1583,7 +1583,8 @@ public async Task DeleteLobby(Lobby lobby) // Post match result to external leaderboard API for every lobby type. // Only QuickMatch responses are expected to carry a ratings body. - await ExternalLeaderboardsClient.PostMatchResultAsync(db, lobby); + // Uses its own short-lived DbContexts internally so this call's HTTP retries don't pin the pooled connection above. + await ExternalLeaderboardsClient.PostMatchResultAsync(factory, lobby); } return bRemoved; From f7911dd08b690a059d1bd4ab9ecf14670ba6677a Mon Sep 17 00:00:00 2001 From: STK0Cervanthes Date: Sat, 15 Aug 2026 03:24:22 +0200 Subject: [PATCH 2/3] fix: clarify comments regarding DB connection handling during external leaderboard requests --- GenOnlineService/ExternalLeaderboardsClient.cs | 3 +-- GenOnlineService/LobbyManager.cs | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/GenOnlineService/ExternalLeaderboardsClient.cs b/GenOnlineService/ExternalLeaderboardsClient.cs index d6008c4..6fc6e12 100644 --- a/GenOnlineService/ExternalLeaderboardsClient.cs +++ b/GenOnlineService/ExternalLeaderboardsClient.cs @@ -132,8 +132,7 @@ public static async Task PostMatchResultAsync(IDbContextFactory db { GetExternalLeaderboardsConfig(out string postUrl, out _, out string postToken, out _); - // Load the match payload. Context is closed before the HTTP call so the pooled connection - // isn't held open for the (potentially multi-minute, with retries) external request below. + // Dispose the read context before the potentially multi-minute external request below. Controllers.MatchHistory_Entry? matchEntry; await using (var readDb = await dbFactory.CreateDbContextAsync()) { diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index e605b7d..12c7f4e 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1583,7 +1583,6 @@ public async Task DeleteLobby(Lobby lobby) // Post match result to external leaderboard API for every lobby type. // Only QuickMatch responses are expected to carry a ratings body. - // Uses its own short-lived DbContexts internally so this call's HTTP retries don't pin the pooled connection above. await ExternalLeaderboardsClient.PostMatchResultAsync(factory, lobby); } From ec0ee8372b216df03c99c8b162737ba7c4f4ab07 Mon Sep 17 00:00:00 2001 From: STK0Cervanthes Date: Sun, 16 Aug 2026 02:31:56 +0200 Subject: [PATCH 3/3] Fix DeleteLobby: use IDbContextFactory instead of unused AppDbContext field --- GenOnlineService/LobbyManager.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 4937c3a..239de9b 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1406,15 +1406,10 @@ public class LobbyManager private Int64 m_NextLobbyID = 0; private readonly IServiceProvider _services; - private readonly AppDbContext _db; public LobbyManager(IServiceProvider services) { _services = services; - - var scope = _services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - var _db = factory.CreateDbContext(); } public async Task Cleanup() @@ -1720,13 +1715,17 @@ public async Task DeleteLobby(Lobby lobby) { try { + using var scope = _services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + if (lobby.State != ELobbyState.COMPLETE) { // make done await lobby.UpdateState(ELobbyState.COMPLETE); // attempt to commit it - await Database.MatchHistory.CommitLobbyToMatchHistory(_db, lobby); + await Database.MatchHistory.CommitLobbyToMatchHistory(db, lobby); } // delete @@ -1740,11 +1739,11 @@ public async Task DeleteLobby(Lobby lobby) lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; // make sure we have a winner - await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(_db, lobby); + await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); // Post match result to external leaderboard API for every lobby type. // Only QuickMatch responses are expected to carry a ratings body. - await ExternalLeaderboardsClient.PostMatchResultAsync(_db, lobby); + await ExternalLeaderboardsClient.PostMatchResultAsync(factory, lobby); } return bRemoved;