perf(detect-anomalies): batch snapshot loads into a single query (HIGH)

DetectAnomaliesUseCase was issuing one ISnapshotRepository.ListByEventAsync
call per event each cycle, with each call rehydrating that event's bets via
Include(s => s.Bets) — O(N) SQLite round-trips and N Include payloads on
every detection cycle.

* Add ISnapshotRepository.ListByEventsAsync(IReadOnlyCollection<EventId>, …)
  returning a per-event dictionary; events with no snapshots in range get
  Array.Empty<OddsSnapshot>() so the caller doesn't need a presence check.
* Implementation uses a single .Where(s => ids.Contains(s.EventCode))
  query and groups in memory.
* DetectAnomaliesUseCase loads the whole batch once before the foreach,
  then ProcessEventAsync receives the per-event slice as a parameter.
* Tests updated to stub the new method; per-event-failure test now
  exercises an AddAsync throw rather than a snapshot-load throw, since
  individual snapshot loads no longer fail per-event.
This commit is contained in:
2026-05-09 15:17:49 +03:00
parent 958d472582
commit 66ae038243
4 changed files with 95 additions and 26 deletions
@@ -38,6 +38,43 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyDictionary<EventId, IReadOnlyList<OddsSnapshot>>> ListByEventsAsync(
IReadOnlyCollection<EventId> eventIds,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventIds);
var result = new Dictionary<EventId, IReadOnlyList<OddsSnapshot>>(eventIds.Count);
if (eventIds.Count == 0)
return result;
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
var fromStr = from.ToString("O");
var toStr = to.ToString("O");
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Where(s => ids.Contains(s.EventCode)
&& s.CapturedAt.CompareTo(fromStr) >= 0
&& s.CapturedAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
var grouped = entities
.GroupBy(e => e.EventCode)
.ToDictionary(g => g.Key, g => g.Select(Mapping.ToDomain).ToList());
foreach (var id in eventIds)
{
result[id] = grouped.TryGetValue(id.Value, out var list)
? list.AsReadOnly()
: Array.Empty<OddsSnapshot>();
}
return result;
}
public async Task AddAsync(OddsSnapshot entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);