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
@@ -79,9 +79,10 @@ public sealed class DetectAnomaliesUseCaseTests
.Returns(new[] { ev }.ToList().AsReadOnly());
_snapshotRepo
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(eventId));
.Returns(SnapshotsByEvent(eventId, BuildFlipTimeline(eventId)));
// No existing anomalies → dedup will not filter anything.
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
@@ -111,9 +112,10 @@ public sealed class DetectAnomaliesUseCaseTests
.Returns(new[] { ev }.ToList().AsReadOnly());
_snapshotRepo
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(eventId));
.Returns(SnapshotsByEvent(eventId, BuildFlipTimeline(eventId)));
// Existing anomaly with same EventId, Kind=SuspensionFlip, and DetectedAt ≈ now (within dedup window).
var existingAnomaly = new Anomaly(
@@ -142,7 +144,9 @@ public sealed class DetectAnomaliesUseCaseTests
[Fact]
public async Task Should_ContinueAfterPerEventFailure_And_ReturnPartialCount()
{
// Arrange: two events — first throws on snapshot load, second has a detectable flip.
// Arrange: two events — persistence on event 1 throws, event 2 has a detectable flip.
// (The batched ListByEventsAsync no longer fails per-event; the per-event try/catch
// protects against persistence failures while iterating detected anomalies.)
var ev1Id = new EventId("33333333");
var ev2Id = new EventId("44444444");
var ev1 = TestFixtures.MakeEvent(ev1Id.Value);
@@ -151,21 +155,23 @@ public sealed class DetectAnomaliesUseCaseTests
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
// Event 1 — snapshot load throws.
// Both events have detectable flip timelines.
_snapshotRepo
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error for event 1"));
// Event 2 — clean flip timeline.
_snapshotRepo
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(ev2Id));
.Returns(SnapshotsByEvent(
(ev1Id, BuildFlipTimeline(ev1Id)),
(ev2Id, BuildFlipTimeline(ev2Id))));
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
// Event 1's anomaly persistence throws; event 2's succeeds.
_anomalyRepo
.AddAsync(Arg.Is<Anomaly>(a => a.EventId == ev1Id), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error for event 1 anomaly"));
var sut = CreateSut();
// Act — must not throw despite event 1 failing.
@@ -193,14 +199,12 @@ public sealed class DetectAnomaliesUseCaseTests
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
_snapshotRepo
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
.ListByEventsAsync(Arg.Any<IReadOnlyCollection<EventId>>(),
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(ev1Id));
_snapshotRepo
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
Arg.Any<CancellationToken>())
.Returns(BuildFlipTimeline(ev2Id));
.Returns(SnapshotsByEvent(
(ev1Id, BuildFlipTimeline(ev1Id)),
(ev2Id, BuildFlipTimeline(ev2Id))));
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
@@ -214,4 +218,14 @@ public sealed class DetectAnomaliesUseCaseTests
count.Should().Be(2, "two events, one flip each → 2 new anomalies");
await _anomalyRepo.Received(2).AddAsync(Arg.Any<Anomaly>(), Arg.Any<CancellationToken>());
}
// ── Helper: build the dictionary returned by ISnapshotRepository.ListByEventsAsync ─
private static IReadOnlyDictionary<EventId, IReadOnlyList<OddsSnapshot>> SnapshotsByEvent(
EventId id, IReadOnlyList<OddsSnapshot> snapshots) =>
new Dictionary<EventId, IReadOnlyList<OddsSnapshot>> { [id] = snapshots };
private static IReadOnlyDictionary<EventId, IReadOnlyList<OddsSnapshot>> SnapshotsByEvent(
params (EventId Id, IReadOnlyList<OddsSnapshot> Snapshots)[] entries) =>
entries.ToDictionary(e => e.Id, e => e.Snapshots);
}