perf(ui): batch event-list snapshot loads + push distinct dimensions to DB (HIGH)

* 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.
This commit is contained in:
2026-05-09 15:29:05 +03:00
parent 286b55986b
commit 857d456b95
3 changed files with 50 additions and 22 deletions
@@ -12,4 +12,16 @@ public interface IEventRepository : IRepository<EventId, Event>
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
/// <summary>
/// Distinct sport codes across the events table. Projects in the database
/// rather than materialising every <see cref="Event"/> on the client.
/// </summary>
Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default);
/// <summary>
/// Distinct ISO-2 country codes across the events table. Projects in the
/// database rather than materialising every <see cref="Event"/>.
/// </summary>
Task<IReadOnlyList<string>> ListDistinctCountryCodesAsync(CancellationToken ct = default);
}
@@ -50,6 +50,28 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
{
var codes = await _db.Events.AsNoTracking()
.Select(e => e.SportCode)
.Distinct()
.ToListAsync(ct);
codes.Sort();
return codes;
}
public async Task<IReadOnlyList<string>> 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);
@@ -68,25 +68,11 @@ public sealed class EventBrowsingService : IEventBrowsingService
history);
}
public async Task<IReadOnlyList<int>> 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<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
=> _events.ListDistinctSportCodesAsync(ct);
public async Task<IReadOnlyList<string>> 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<IReadOnlyList<string>> 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<EventListItem>();
// 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<EventListItem>(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<OddsSnapshot>();
var matching = snapshots
.Where(s => s.Source == source)