From 857d456b9566a94187416082d24303268d57acd0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 15:29:05 +0300 Subject: [PATCH] perf(ui): batch event-list snapshot loads + push distinct dimensions to DB (HIGH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * EventBrowsingService.BuildListAsync issued one snapshot query per event on every page render — N+1 against SQLite, with each round-trip hauling the full bet graph via Include(Bets). Replaced with a single ISnapshotRepository.ListByEventsAsync batch. * ListKnownSportCodesAsync / ListKnownCountryCodesAsync used to materialise every Event row to compute Distinct() in memory. Pushed to EF projection via two new IEventRepository methods (ListDistinctSportCodesAsync, ListDistinctCountryCodesAsync) implemented as .Select(...).Distinct().ToListAsync — single SELECT DISTINCT. --- .../Abstractions/IEventRepository.cs | 12 ++++++ .../Repositories/EventRepository.cs | 22 +++++++++++ .../Services/EventBrowsingService.cs | 38 ++++++++----------- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/Marathon.Application/Abstractions/IEventRepository.cs b/src/Marathon.Application/Abstractions/IEventRepository.cs index 8209476..fc99e10 100644 --- a/src/Marathon.Application/Abstractions/IEventRepository.cs +++ b/src/Marathon.Application/Abstractions/IEventRepository.cs @@ -12,4 +12,16 @@ public interface IEventRepository : IRepository Task> ListByDateRangeAsync(DateRange range, CancellationToken ct = default); Task> ListBySportAsync(SportCode sport, CancellationToken ct = default); + + /// + /// Distinct sport codes across the events table. Projects in the database + /// rather than materialising every on the client. + /// + Task> ListDistinctSportCodesAsync(CancellationToken ct = default); + + /// + /// Distinct ISO-2 country codes across the events table. Projects in the + /// database rather than materialising every . + /// + Task> ListDistinctCountryCodesAsync(CancellationToken ct = default); } diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs index dfb6794..9167c10 100644 --- a/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs +++ b/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs @@ -50,6 +50,28 @@ internal sealed class EventRepository : IEventRepository return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); } + public async Task> ListDistinctSportCodesAsync(CancellationToken ct = default) + { + var codes = await _db.Events.AsNoTracking() + .Select(e => e.SportCode) + .Distinct() + .ToListAsync(ct); + + codes.Sort(); + return codes; + } + + public async Task> ListDistinctCountryCodesAsync(CancellationToken ct = default) + { + var codes = await _db.Events.AsNoTracking() + .Select(e => e.CountryCode) + .Distinct() + .ToListAsync(ct); + + codes.Sort(StringComparer.OrdinalIgnoreCase); + return codes; + } + public async Task AddAsync(Event entity, CancellationToken ct = default) { var efEntity = Mapping.ToEntity(entity); diff --git a/src/Marathon.UI/Services/EventBrowsingService.cs b/src/Marathon.UI/Services/EventBrowsingService.cs index 0780902..9e8579c 100644 --- a/src/Marathon.UI/Services/EventBrowsingService.cs +++ b/src/Marathon.UI/Services/EventBrowsingService.cs @@ -68,25 +68,11 @@ public sealed class EventBrowsingService : IEventBrowsingService history); } - public async Task> ListKnownSportCodesAsync(CancellationToken ct) - { - var all = await _events.ListAsync(ct).ConfigureAwait(false); - return all - .Select(static e => e.Sport.Value) - .Distinct() - .OrderBy(static x => x) - .ToList(); - } + public Task> ListKnownSportCodesAsync(CancellationToken ct) + => _events.ListDistinctSportCodesAsync(ct); - public async Task> ListKnownCountryCodesAsync(CancellationToken ct) - { - var all = await _events.ListAsync(ct).ConfigureAwait(false); - return all - .Select(static e => e.CountryCode) - .Distinct() - .OrderBy(static x => x, StringComparer.OrdinalIgnoreCase) - .ToList(); - } + public Task> ListKnownCountryCodesAsync(CancellationToken ct) + => _events.ListDistinctCountryCodesAsync(ct); // ---------------- internals ---------------- @@ -121,18 +107,26 @@ public sealed class EventBrowsingService : IEventBrowsingService var sorted = ApplySort(filtered, filter.SortKey, filter.SortDescending); var materialized = sorted.ToList(); + if (materialized.Count == 0) + return Array.Empty(); - // Read each event's latest matching snapshot to populate the preview odds. + // Single batched snapshot query for all events on the page (replaces the + // prior per-event ListByEventAsync round-trip — N+1 against SQLite). The + // repository fills empty entries for events with no snapshots in range. var rangeFrom = filter.Dates.From.AddDays(-2); var rangeTo = filter.Dates.To.AddDays(2); + var ids = materialized.Select(e => e.Id).ToList(); + var snapshotsByEvent = await _snapshots + .ListByEventsAsync(ids, rangeFrom, rangeTo, ct) + .ConfigureAwait(false); var rows = new List(materialized.Count); foreach (var ev in materialized) { ct.ThrowIfCancellationRequested(); - var snapshots = await _snapshots - .ListByEventAsync(ev.Id, rangeFrom, rangeTo, ct) - .ConfigureAwait(false); + var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found) + ? found + : Array.Empty(); var matching = snapshots .Where(s => s.Source == source)