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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user