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:
@@ -30,10 +30,10 @@ public sealed class DetectAnomaliesUseCase
|
||||
// Dedup window: two anomalies for the same event within this window are considered duplicates.
|
||||
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly ISnapshotRepository _snapshotRepo;
|
||||
private readonly IAnomalyRepository _anomalyRepo;
|
||||
private readonly AnomalyOptions _options;
|
||||
private readonly IAnomalyRepository _anomalyRepo;
|
||||
private readonly AnomalyOptions _options;
|
||||
private readonly ILogger<DetectAnomaliesUseCase> _logger;
|
||||
|
||||
public DetectAnomaliesUseCase(
|
||||
@@ -43,11 +43,11 @@ public sealed class DetectAnomaliesUseCase
|
||||
IOptions<AnomalyOptions> options,
|
||||
ILogger<DetectAnomaliesUseCase> logger)
|
||||
{
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -67,13 +67,16 @@ public sealed class DetectAnomaliesUseCase
|
||||
var events = await _eventRepo.ListAsync(ct);
|
||||
int newAnomalyCount = 0;
|
||||
|
||||
var now = MoscowTime.Now;
|
||||
var now = MoscowTime.Now;
|
||||
var from = now - SnapshotLookback;
|
||||
|
||||
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
|
||||
// and slice per-event in the loop. Previously this was reloaded per event
|
||||
// (O(N_events) round-trips). Reviewer W1, Phase 7.
|
||||
// and index them by event so dedup is O(1) per event instead of scanning the
|
||||
// whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
|
||||
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
|
||||
var existingByEvent = existingAnomalies
|
||||
.GroupBy(a => a.EventId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Single batched query for all events' snapshots — replaces the prior
|
||||
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
|
||||
@@ -90,7 +93,10 @@ public sealed class DetectAnomaliesUseCase
|
||||
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
|
||||
? found
|
||||
: Array.Empty<OddsSnapshot>();
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, ct);
|
||||
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
|
||||
? slice
|
||||
: new List<Anomaly>();
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingForEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -117,7 +123,7 @@ public sealed class DetectAnomaliesUseCase
|
||||
AnomalyDetector detector,
|
||||
Event ev,
|
||||
IReadOnlyList<OddsSnapshot> snapshots,
|
||||
IReadOnlyList<Anomaly> existingAnomalies,
|
||||
List<Anomaly> existingForEvent,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var detected = detector.Detect(ev.Id, snapshots);
|
||||
@@ -125,11 +131,6 @@ public sealed class DetectAnomaliesUseCase
|
||||
if (detected.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Slice the cycle-wide existing-anomaly list to just this event for dedup.
|
||||
var existingForEvent = existingAnomalies
|
||||
.Where(a => a.EventId == ev.Id)
|
||||
.ToList();
|
||||
|
||||
int persisted = 0;
|
||||
foreach (var anomaly in detected)
|
||||
{
|
||||
@@ -151,7 +152,7 @@ public sealed class DetectAnomaliesUseCase
|
||||
// and their DetectedAt timestamps fall within the dedup window.
|
||||
return existing.Any(a =>
|
||||
a.EventId == candidate.EventId &&
|
||||
a.Kind == candidate.Kind &&
|
||||
a.Kind == candidate.Kind &&
|
||||
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
|
||||
DedupWindow.TotalMinutes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user