perf: batch repository reads, index snapshots, centralize date encoding

- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
  6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
  results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
  load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
  (feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
  _EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
  shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
  DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.

Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
This commit is contained in:
2026-05-28 22:34:08 +03:00
parent 0d52b7beff
commit f294255f10
30 changed files with 522 additions and 145 deletions
@@ -28,10 +28,12 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
{
ArgumentNullException.ThrowIfNull(filter);
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
// Date filter pushed to SQL; severity needs the parsed score and sport needs
// the event join, so those two stay in memory over the smaller returned set.
var all = await _anomalies.ListByDateRangeAsync(filter.From, filter.To, ct).ConfigureAwait(false);
if (all.Count == 0) return Array.Empty<AnomalyListItem>();
// Resolve event metadata in one pass — distinct EventIds only.
// Resolve event metadata in one batched pass — distinct EventIds only.
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
var items = new List<AnomalyListItem>(all.Count);
@@ -44,7 +46,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
}
}
// Apply filters in-memory (small list, UI page).
// Remaining filters in-memory (page-sized set).
IEnumerable<AnomalyListItem> filtered = items;
if (filter.MinSeverity is { } minSeverity)
@@ -57,16 +59,6 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
}
if (filter.From is { } from)
{
filtered = filtered.Where(i => i.DetectedAt >= from);
}
if (filter.To is { } to)
{
filtered = filtered.Where(i => i.DetectedAt <= to);
}
return filtered
.OrderByDescending(static i => i.DetectedAt)
.ToList();
@@ -88,16 +80,9 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
return new AnomalyDetailVm(item, pre, post);
}
public async Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
{
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
var count = 0;
foreach (var anomaly in all)
{
if (anomaly.DetectedAt > since) count++;
}
return count;
}
public Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
// Server-side COUNT(*) — no longer materialises the table to count.
=> _anomalies.CountSinceAsync(since, ct);
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
{
@@ -125,14 +110,8 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
.Distinct()
.ToList();
var dict = new Dictionary<DomainEventId, Event>(distinct.Count);
foreach (var eid in distinct)
{
ct.ThrowIfCancellationRequested();
var ev = await _events.GetAsync(eid, ct).ConfigureAwait(false);
if (ev is not null) dict[eid] = ev;
}
return dict;
// Single batched query instead of one GetAsync per distinct event (N+1).
return await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
}
private static bool TryProject(
@@ -151,7 +130,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
var country = ev?.CountryCode ?? string.Empty;
var league = ev?.LeagueId ?? string.Empty;
var title = ev is not null
? $"{ev.Side1Name} vs {ev.Side2Name}"
? ev.Title
: anomaly.EventId.Value;
var preSnap = ToSnapshot(dto.PreSuspension);