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
@@ -75,13 +75,22 @@ public sealed class DetectAnomaliesUseCase
// (O(N_events) round-trips). Reviewer W1, Phase 7.
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
// Single batched query for all events' snapshots — replaces the prior
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
// payloads). Returns an empty list for events with no snapshots in range.
var eventIds = events.Select(e => e.Id).ToList();
var snapshotsByEvent = await _snapshotRepo.ListByEventsAsync(eventIds, from, now, ct);
foreach (var ev in events)
{
ct.ThrowIfCancellationRequested();
try
{
newAnomalyCount += await ProcessEventAsync(detector, ev, from, now, existingAnomalies, ct);
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
? found
: Array.Empty<OddsSnapshot>();
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, ct);
}
catch (OperationCanceledException)
{
@@ -107,13 +116,11 @@ public sealed class DetectAnomaliesUseCase
private async Task<int> ProcessEventAsync(
AnomalyDetector detector,
Event ev,
DateTimeOffset from,
DateTimeOffset to,
IReadOnlyList<OddsSnapshot> snapshots,
IReadOnlyList<Anomaly> existingAnomalies,
CancellationToken ct)
{
var snapshots = await _snapshotRepo.ListByEventAsync(ev.Id, from, to, ct);
var detected = detector.Detect(ev.Id, snapshots);
var detected = detector.Detect(ev.Id, snapshots);
if (detected.Count == 0)
return 0;