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:
@@ -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);
|
||||
|
||||
@@ -40,15 +40,15 @@ public sealed class BetJournalService : IBetJournalService
|
||||
if (report.Bets.Count == 0)
|
||||
return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>());
|
||||
|
||||
// Resolve event titles in one pass — distinct ids only.
|
||||
// Resolve event titles in one batched query — distinct ids only (was N+1).
|
||||
// Missing events (pruned by snapshot retention) fall back to the raw id.
|
||||
var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList();
|
||||
var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
|
||||
var titles = new Dictionary<DomainEventId, string>(distinctIds.Count);
|
||||
foreach (var id in distinctIds)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var ev = await _events.GetAsync(id, ct).ConfigureAwait(false);
|
||||
titles[id] = ev is not null
|
||||
? string.Concat(ev.Side1Name, " vs ", ev.Side2Name)
|
||||
titles[id] = events.TryGetValue(id, out var ev)
|
||||
? ev.Title
|
||||
: id.Value;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,15 +83,17 @@ public sealed class EventBrowsingService : IEventBrowsingService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
var range = new DateRange(filter.Dates.From, filter.Dates.To);
|
||||
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
|
||||
// Date range + sport filter pushed to SQL (was: load the whole date range,
|
||||
// then filter sports in memory). Country/search filtering and locale-aware
|
||||
// sorting stay here to preserve the Cyrillic ordinal semantics that SQLite's
|
||||
// BINARY collation would change.
|
||||
var query = new EventQuery(
|
||||
new DateRange(filter.Dates.From, filter.Dates.To),
|
||||
filter.SportCodes);
|
||||
var events = await _events.QueryAsync(query, ct).ConfigureAwait(false);
|
||||
|
||||
// Apply non-temporal filters in-memory — list size is small (UI page).
|
||||
IEnumerable<Event> filtered = events;
|
||||
|
||||
if (filter.SportCodes is { Count: > 0 } sports)
|
||||
filtered = filtered.Where(e => sports.Contains(e.Sport.Value));
|
||||
|
||||
if (filter.CountryCodes is { Count: > 0 } countries)
|
||||
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user