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:
2026-05-28 22:34:08 +03:00
parent 0d52b7beff
commit f294255f10
30 changed files with 522 additions and 145 deletions
@@ -1,4 +1,3 @@
using System.Globalization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
@@ -10,16 +9,15 @@ namespace Marathon.Infrastructure.Persistence;
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
/// Domain invariants are enforced on the domain side; mapping is purely structural.
/// </summary>
/// <remarks>
/// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and
/// decoded exclusively through <see cref="SqliteDateText"/> so the write format and
/// the repositories' range-predicate format can never drift apart.
/// </remarks>
internal static class Mapping
{
// ScheduledAt / CapturedAt / DetectedAt / CompletedAt are written via
// DateTimeOffset.ToString("O") — round-trip ISO 8601. Parse with the
// invariant culture and RoundtripKind so a non-en-US thread culture
// (or a future locale change) cannot corrupt the round-trip.
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
// ─── Bet scope discriminator constants ────────────────────────────────────
private const int ScopeMatch = 0;
private const int ScopeMatch = 0;
private const int ScopePeriod = 1;
// ─── Event ───────────────────────────────────────────────────────────────
@@ -31,7 +29,7 @@ internal static class Mapping
CountryCode = domain.CountryCode,
LeagueId = domain.LeagueId,
Category = domain.Category,
ScheduledAt = domain.ScheduledAt.ToString("O"),
ScheduledAt = SqliteDateText.Key(domain.ScheduledAt),
Side1Name = domain.Side1Name,
Side2Name = domain.Side2Name,
EventPath = domain.EventPath,
@@ -44,7 +42,7 @@ internal static class Mapping
CountryCode: entity.CountryCode,
LeagueId: entity.LeagueId,
Category: entity.Category,
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles),
ScheduledAt: SqliteDateText.Parse(entity.ScheduledAt),
Side1Name: entity.Side1Name,
Side2Name: entity.Side2Name)
{
@@ -57,7 +55,7 @@ internal static class Mapping
new()
{
EventCode = domain.EventId.Value,
CapturedAt = domain.CapturedAt.ToString("O"),
CapturedAt = SqliteDateText.Key(domain.CapturedAt),
Source = (int)domain.Source,
Bets = domain.Bets.Select(ToEntity).ToList(),
};
@@ -65,7 +63,7 @@ internal static class Mapping
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
new(
eventId: new EventId(entity.EventCode),
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles),
capturedAt: SqliteDateText.Parse(entity.CapturedAt),
source: (OddsSource)entity.Source,
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
@@ -86,7 +84,7 @@ internal static class Mapping
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
@@ -109,7 +107,7 @@ internal static class Mapping
Side1Score = domain.Side1Score,
Side2Score = domain.Side2Score,
WinnerSide = (int)domain.WinnerSide,
CompletedAt = domain.CompletedAt.ToString("O"),
CompletedAt = SqliteDateText.Key(domain.CompletedAt),
};
public static EventResult ToDomain(EventResultEntity entity) =>
@@ -118,7 +116,7 @@ internal static class Mapping
Side1Score: entity.Side1Score,
Side2Score: entity.Side2Score,
WinnerSide: (Side)entity.WinnerSide,
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles));
CompletedAt: SqliteDateText.Parse(entity.CompletedAt));
// ─── Anomaly ──────────────────────────────────────────────────────────────
@@ -127,7 +125,7 @@ internal static class Mapping
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
DetectedAt = domain.DetectedAt.ToString("O"),
DetectedAt = SqliteDateText.Key(domain.DetectedAt),
Kind = (int)domain.Kind,
Score = domain.Score,
EvidenceJson = domain.EvidenceJson,
@@ -137,7 +135,7 @@ internal static class Mapping
new(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles),
DetectedAt: SqliteDateText.Parse(entity.DetectedAt),
Kind: (AnomalyKind)entity.Kind,
Score: entity.Score,
EvidenceJson: entity.EvidenceJson);
@@ -172,7 +170,7 @@ internal static class Mapping
Value = domain.Selection.Value?.Value,
Rate = domain.Selection.Rate.Value,
Stake = domain.Stake,
PlacedAt = domain.PlacedAt.ToString("O"),
PlacedAt = SqliteDateText.Key(domain.PlacedAt),
Outcome = (int)domain.Outcome,
Notes = domain.Notes,
};
@@ -181,7 +179,7 @@ internal static class Mapping
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
@@ -198,7 +196,7 @@ internal static class Mapping
EventId: new EventId(entity.EventCode),
Selection: selection,
Stake: entity.Stake,
PlacedAt: DateTimeOffset.Parse(entity.PlacedAt, CultureInfo.InvariantCulture, RoundtripStyles),
PlacedAt: SqliteDateText.Parse(entity.PlacedAt),
Outcome: (BetOutcome)entity.Outcome,
Notes: entity.Notes);
}