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
@@ -5,4 +5,22 @@ namespace Marathon.Application.Abstractions;
/// <summary> /// <summary>
/// Repository for <see cref="Anomaly"/> domain entities. /// Repository for <see cref="Anomaly"/> domain entities.
/// </summary> /// </summary>
public interface IAnomalyRepository : IRepository<Guid, Anomaly>; public interface IAnomalyRepository : IRepository<Guid, Anomaly>
{
/// <summary>
/// Server-side count of anomalies detected strictly after <paramref name="since"/>.
/// Backs the unread badge without materialising the table.
/// </summary>
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
/// <summary>
/// Anomalies whose <see cref="Anomaly.DetectedAt"/> falls in the inclusive
/// [<paramref name="from"/>..<paramref name="to"/>] window (either bound may be
/// null for open-ended), ordered newest-first. Pushes the temporal filter to SQL;
/// severity / sport filtering remains a service concern (needs the event join).
/// </summary>
Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct = default);
}
@@ -11,8 +11,27 @@ public interface IEventRepository : IRepository<EventId, Event>
{ {
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default); Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
/// <summary>
/// Date-range + sport-filtered query pushed to the database. Replaces the
/// "load the whole date range then filter sports in memory" path on the list
/// pages. Locale-sensitive search and sort remain a service-layer concern.
/// </summary>
Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default);
/// <summary>
/// Batched point-lookup: loads many events in a single query, keyed by
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
/// </summary>
Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default);
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default); Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
/// <summary>Server-side total event count (dashboard summary).</summary>
Task<int> CountAsync(CancellationToken ct = default);
/// <summary> /// <summary>
/// Distinct sport codes across the events table. Projects in the database /// Distinct sport codes across the events table. Projects in the database
/// rather than materialising every <see cref="Event"/> on the client. /// rather than materialising every <see cref="Event"/> on the client.
@@ -6,4 +6,14 @@ namespace Marathon.Application.Abstractions;
/// <summary> /// <summary>
/// Repository for <see cref="EventResult"/> domain entities. /// Repository for <see cref="EventResult"/> domain entities.
/// </summary> /// </summary>
public interface IResultRepository : IRepository<EventId, EventResult>; public interface IResultRepository : IRepository<EventId, EventResult>
{
/// <summary>
/// Batched point-lookup: loads many results in a single query, keyed by
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
/// </summary>
Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default);
}
@@ -16,6 +16,12 @@ public interface ISnapshotRepository
{ {
Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default); Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default);
/// <summary>
/// Server-side count of snapshots captured at or after <paramref name="since"/>.
/// Backs the dashboard "snapshots today" stat without materialising rows.
/// </summary>
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync( Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId, EventId eventId,
DateTimeOffset from, DateTimeOffset from,
@@ -0,0 +1,13 @@
namespace Marathon.Application.Storage;
/// <summary>
/// Database-pushdown query for the event list pages: an inclusive date range plus
/// an optional sport-code filter. Locale-sensitive search and sort are deliberately
/// NOT part of this contract — they stay in the service layer where Cyrillic
/// ordinal semantics are preserved (SQLite BINARY collation would change them).
/// </summary>
/// <param name="Dates">Inclusive scheduled-at window.</param>
/// <param name="SportCodes">When non-empty, restricts to these sport codes. Null/empty = all sports.</param>
public sealed record EventQuery(
DateRange Dates,
IReadOnlyCollection<int>? SportCodes = null);
@@ -54,16 +54,17 @@ public sealed class BuildBetJournalReportUseCase
var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList(); var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList();
// Resolve closing snapshot per event using a single-row repo call — // Batch the event loads (was N+1). The closing-snapshot lookup stays per-event
// pushes the ORDER BY / LIMIT 1 down to SQLite rather than materialising // because it pushes ORDER BY / LIMIT 1 down to SQLite (one indexed row each)
// every snapshot in a 30-day window. // and is parameterised by that event's ScheduledAt.
var events = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var closingByEvent = new Dictionary<DomainEventId, OddsSnapshot?>(distinctEventIds.Count); var closingByEvent = new Dictionary<DomainEventId, OddsSnapshot?>(distinctEventIds.Count);
foreach (var eventId in distinctEventIds) foreach (var eventId in distinctEventIds)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false); if (!events.TryGetValue(eventId, out var ev))
if (ev is null)
{ {
closingByEvent[eventId] = null; closingByEvent[eventId] = null;
continue; continue;
@@ -30,10 +30,10 @@ public sealed class DetectAnomaliesUseCase
// Dedup window: two anomalies for the same event within this window are considered duplicates. // Dedup window: two anomalies for the same event within this window are considered duplicates.
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1); private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
private readonly IEventRepository _eventRepo; private readonly IEventRepository _eventRepo;
private readonly ISnapshotRepository _snapshotRepo; private readonly ISnapshotRepository _snapshotRepo;
private readonly IAnomalyRepository _anomalyRepo; private readonly IAnomalyRepository _anomalyRepo;
private readonly AnomalyOptions _options; private readonly AnomalyOptions _options;
private readonly ILogger<DetectAnomaliesUseCase> _logger; private readonly ILogger<DetectAnomaliesUseCase> _logger;
public DetectAnomaliesUseCase( public DetectAnomaliesUseCase(
@@ -43,11 +43,11 @@ public sealed class DetectAnomaliesUseCase
IOptions<AnomalyOptions> options, IOptions<AnomalyOptions> options,
ILogger<DetectAnomaliesUseCase> logger) ILogger<DetectAnomaliesUseCase> logger)
{ {
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo)); _eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo)); _snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo)); _anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value; _options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
/// <summary> /// <summary>
@@ -67,13 +67,16 @@ public sealed class DetectAnomaliesUseCase
var events = await _eventRepo.ListAsync(ct); var events = await _eventRepo.ListAsync(ct);
int newAnomalyCount = 0; int newAnomalyCount = 0;
var now = MoscowTime.Now; var now = MoscowTime.Now;
var from = now - SnapshotLookback; var from = now - SnapshotLookback;
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle // 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 // and index them by event so dedup is O(1) per event instead of scanning the
// (O(N_events) round-trips). Reviewer W1, Phase 7. // whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
var existingAnomalies = await _anomalyRepo.ListAsync(ct); 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 // Single batched query for all events' snapshots — replaces the prior
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets) // 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) var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
? found ? found
: Array.Empty<OddsSnapshot>(); : 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) catch (OperationCanceledException)
{ {
@@ -117,7 +123,7 @@ public sealed class DetectAnomaliesUseCase
AnomalyDetector detector, AnomalyDetector detector,
Event ev, Event ev,
IReadOnlyList<OddsSnapshot> snapshots, IReadOnlyList<OddsSnapshot> snapshots,
IReadOnlyList<Anomaly> existingAnomalies, List<Anomaly> existingForEvent,
CancellationToken ct) CancellationToken ct)
{ {
var detected = detector.Detect(ev.Id, snapshots); var detected = detector.Detect(ev.Id, snapshots);
@@ -125,11 +131,6 @@ public sealed class DetectAnomaliesUseCase
if (detected.Count == 0) if (detected.Count == 0)
return 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; int persisted = 0;
foreach (var anomaly in detected) foreach (var anomaly in detected)
{ {
@@ -151,7 +152,7 @@ public sealed class DetectAnomaliesUseCase
// and their DetectedAt timestamps fall within the dedup window. // and their DetectedAt timestamps fall within the dedup window.
return existing.Any(a => return existing.Any(a =>
a.EventId == candidate.EventId && a.EventId == candidate.EventId &&
a.Kind == candidate.Kind && a.Kind == candidate.Kind &&
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <= Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
DedupWindow.TotalMinutes); DedupWindow.TotalMinutes);
} }
@@ -75,29 +75,16 @@ public sealed class EvaluateAnomalyOutcomesUseCase
return EmptyReport(); return EmptyReport();
} }
// Build event + result lookups — distinct keys only to avoid quadratic loads. // Batched lookups — a single query each, replacing the prior per-event
// TODO (perf, future): batch via IEventRepository.GetManyAsync / IResultRepository.GetManyAsync // GetAsync round-trip (N+1 against SQLite).
// once the repositories expose them. Today the per-event GetAsync round-trip is acceptable
// because anomaly volumes are bounded (1 row per suspension interval per event).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList(); var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
var eventLookup = new Dictionary<DomainEventId, Event>(distinctEventIds.Count); var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var resultLookup = new Dictionary<DomainEventId, EventResult>(distinctEventIds.Count); var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var eventTitles = new Dictionary<DomainEventId, string>(distinctEventIds.Count);
foreach (var id in distinctEventIds)
{
ct.ThrowIfCancellationRequested();
var ev = await _events.GetAsync(id, ct).ConfigureAwait(false); var eventTitles = new Dictionary<DomainEventId, string>(eventLookup.Count);
if (ev is not null) foreach (var (id, ev) in eventLookup)
{ eventTitles[id] = ev.Title;
eventLookup[id] = ev;
eventTitles[id] = string.Concat(ev.Side1Name, " vs ", ev.Side2Name);
}
var res = await _results.GetAsync(id, ct).ConfigureAwait(false);
if (res is not null) resultLookup[id] = res;
}
// Evaluate every anomaly through the pure domain function. // Evaluate every anomaly through the pure domain function.
var resolved = new List<ResolvedAnomaly>(); var resolved = new List<ResolvedAnomaly>();
@@ -72,10 +72,10 @@ public sealed class PullResultsUseCase
IResultRepository resultRepo, IResultRepository resultRepo,
ILogger<PullResultsUseCase> logger) ILogger<PullResultsUseCase> logger)
{ {
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper)); _scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo)); _eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo)); _resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
/// <summary> /// <summary>
@@ -149,12 +149,13 @@ public sealed class PullResultsUseCase
{ {
if (selection is { Count: > 0 }) if (selection is { Count: > 0 })
{ {
// Batched load (was N+1); preserve the caller's selection order and
// silently drop ids with no stored event.
var events = await _eventRepo.GetManyAsync(selection, ct).ConfigureAwait(false);
var resolved = new List<Event>(selection.Count); var resolved = new List<Event>(selection.Count);
foreach (var id in selection) foreach (var id in selection)
{ {
ct.ThrowIfCancellationRequested(); if (events.TryGetValue(id, out var ev))
var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false);
if (ev is not null)
resolved.Add(ev); resolved.Add(ev);
} }
return resolved; return resolved;
@@ -63,29 +63,16 @@ public sealed class RunBacktestUseCase
return BacktestSimulator.Run(strategy, Array.Empty<BacktestCandidate>()); return BacktestSimulator.Run(strategy, Array.Empty<BacktestCandidate>());
} }
// Distinct event lookups — minimises repo calls. // Batched lookups — a single query each, replacing the prior per-event
// TODO (perf, future): batch via IEventRepository.GetManyAsync / // GetAsync round-trip (N+1 against SQLite).
// IResultRepository.GetManyAsync once those exist — currently shared
// with EvaluateAnomalyOutcomesUseCase, acceptable at expected volumes.
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList(); var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
var eventLookup = new Dictionary<DomainEventId, Event>(distinctEventIds.Count); var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var resultLookup = new Dictionary<DomainEventId, EventResult>(distinctEventIds.Count); var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(distinctEventIds.Count);
foreach (var id in distinctEventIds)
{
ct.ThrowIfCancellationRequested();
var ev = await _events.GetAsync(id, ct).ConfigureAwait(false); var titles = new Dictionary<DomainEventId, string>(eventLookup.Count);
if (ev is not null) foreach (var (id, ev) in eventLookup)
{ titles[id] = ev.Title;
eventLookup[id] = ev;
titles[id] = string.Concat(ev.Side1Name, " vs ", ev.Side2Name);
}
var res = await _results.GetAsync(id, ct).ConfigureAwait(false);
if (res is not null) resultLookup[id] = res;
}
var candidates = new List<BacktestCandidate>(anomalies.Count); var candidates = new List<BacktestCandidate>(anomalies.Count);
foreach (var anomaly in anomalies) foreach (var anomaly in anomalies)
+7
View File
@@ -63,4 +63,11 @@ public sealed record Event(
/// numeric event ID. /// numeric event ID.
/// </remarks> /// </remarks>
public string? EventPath { get; init; } public string? EventPath { get; init; }
/// <summary>
/// Display title in the canonical "{Side1Name} vs {Side2Name}" form. Single
/// source for the home-vs-away join that was previously duplicated across the
/// report use cases and list/feed services.
/// </summary>
public string Title => $"{Side1Name} vs {Side2Name}";
} }
@@ -0,0 +1,43 @@
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
[DbContext(typeof(MarathonDbContext))]
[Migration("20260528000000_AddSnapshotCapturedAtIndexes")]
public partial class AddSnapshotCapturedAtIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Composite index for the dominant read shape: filter by EventCode + a
// CapturedAt range, frequently with ORDER BY CapturedAt. Lets SQLite serve
// both the predicate and the ordering from the index rather than scanning.
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode_CapturedAt",
table: "Snapshots",
columns: new[] { "EventCode", "CapturedAt" });
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode_Source_CapturedAt",
table: "Snapshots",
columns: new[] { "EventCode", "Source", "CapturedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Snapshots_EventCode_Source_CapturedAt",
table: "Snapshots");
migrationBuilder.DropIndex(
name: "IX_Snapshots_EventCode_CapturedAt",
table: "Snapshots");
}
}
@@ -92,6 +92,8 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
b.Property<int>("Source").HasColumnType("INTEGER"); b.Property<int>("Source").HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode"); b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots"); b.ToTable("Snapshots");
}); });
@@ -18,6 +18,17 @@ internal sealed class SnapshotConfiguration : IEntityTypeConfiguration<SnapshotE
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode"); builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
// Snapshots is the largest table (live cadence 510s, 90-day retention) and
// every hot read filters EventCode + CapturedAt range, often with an ORDER BY
// CapturedAt. These composite indexes let SQLite satisfy the filter and the
// ordering from the index instead of scanning + sorting the table.
builder.HasIndex(s => new { s.EventCode, s.CapturedAt })
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
builder.HasIndex(s => new { s.EventCode, s.Source, s.CapturedAt })
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
builder.HasMany(s => s.Bets) builder.HasMany(s => s.Bets)
.WithOne(b => b.Snapshot) .WithOne(b => b.Snapshot)
.HasForeignKey(b => b.SnapshotId) .HasForeignKey(b => b.SnapshotId)
@@ -1,4 +1,3 @@
using System.Globalization;
using Marathon.Domain.Entities; using Marathon.Domain.Entities;
using Marathon.Domain.Enums; using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects; using Marathon.Domain.ValueObjects;
@@ -10,16 +9,15 @@ namespace Marathon.Infrastructure.Persistence;
/// Mapping helpers that translate between domain objects and EF Core persistence entities. /// Mapping helpers that translate between domain objects and EF Core persistence entities.
/// Domain invariants are enforced on the domain side; mapping is purely structural. /// Domain invariants are enforced on the domain side; mapping is purely structural.
/// </summary> /// </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 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 ──────────────────────────────────── // ─── Bet scope discriminator constants ────────────────────────────────────
private const int ScopeMatch = 0; private const int ScopeMatch = 0;
private const int ScopePeriod = 1; private const int ScopePeriod = 1;
// ─── Event ─────────────────────────────────────────────────────────────── // ─── Event ───────────────────────────────────────────────────────────────
@@ -31,7 +29,7 @@ internal static class Mapping
CountryCode = domain.CountryCode, CountryCode = domain.CountryCode,
LeagueId = domain.LeagueId, LeagueId = domain.LeagueId,
Category = domain.Category, Category = domain.Category,
ScheduledAt = domain.ScheduledAt.ToString("O"), ScheduledAt = SqliteDateText.Key(domain.ScheduledAt),
Side1Name = domain.Side1Name, Side1Name = domain.Side1Name,
Side2Name = domain.Side2Name, Side2Name = domain.Side2Name,
EventPath = domain.EventPath, EventPath = domain.EventPath,
@@ -44,7 +42,7 @@ internal static class Mapping
CountryCode: entity.CountryCode, CountryCode: entity.CountryCode,
LeagueId: entity.LeagueId, LeagueId: entity.LeagueId,
Category: entity.Category, Category: entity.Category,
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles), ScheduledAt: SqliteDateText.Parse(entity.ScheduledAt),
Side1Name: entity.Side1Name, Side1Name: entity.Side1Name,
Side2Name: entity.Side2Name) Side2Name: entity.Side2Name)
{ {
@@ -57,7 +55,7 @@ internal static class Mapping
new() new()
{ {
EventCode = domain.EventId.Value, EventCode = domain.EventId.Value,
CapturedAt = domain.CapturedAt.ToString("O"), CapturedAt = SqliteDateText.Key(domain.CapturedAt),
Source = (int)domain.Source, Source = (int)domain.Source,
Bets = domain.Bets.Select(ToEntity).ToList(), Bets = domain.Bets.Select(ToEntity).ToList(),
}; };
@@ -65,7 +63,7 @@ internal static class Mapping
public static OddsSnapshot ToDomain(SnapshotEntity entity) => public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
new( new(
eventId: new EventId(entity.EventCode), eventId: new EventId(entity.EventCode),
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles), capturedAt: SqliteDateText.Parse(entity.CapturedAt),
source: (OddsSource)entity.Source, source: (OddsSource)entity.Source,
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly()); bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
@@ -86,7 +84,7 @@ internal static class Mapping
{ {
var scope = entity.Scope switch var scope = entity.Scope switch
{ {
ScopeMatch => (BetScope)MatchScope.Instance, ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value), ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException( _ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"), $"Unknown BetScope discriminator: {entity.Scope}"),
@@ -109,7 +107,7 @@ internal static class Mapping
Side1Score = domain.Side1Score, Side1Score = domain.Side1Score,
Side2Score = domain.Side2Score, Side2Score = domain.Side2Score,
WinnerSide = (int)domain.WinnerSide, WinnerSide = (int)domain.WinnerSide,
CompletedAt = domain.CompletedAt.ToString("O"), CompletedAt = SqliteDateText.Key(domain.CompletedAt),
}; };
public static EventResult ToDomain(EventResultEntity entity) => public static EventResult ToDomain(EventResultEntity entity) =>
@@ -118,7 +116,7 @@ internal static class Mapping
Side1Score: entity.Side1Score, Side1Score: entity.Side1Score,
Side2Score: entity.Side2Score, Side2Score: entity.Side2Score,
WinnerSide: (Side)entity.WinnerSide, WinnerSide: (Side)entity.WinnerSide,
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles)); CompletedAt: SqliteDateText.Parse(entity.CompletedAt));
// ─── Anomaly ────────────────────────────────────────────────────────────── // ─── Anomaly ──────────────────────────────────────────────────────────────
@@ -127,7 +125,7 @@ internal static class Mapping
{ {
Id = domain.Id.ToString(), Id = domain.Id.ToString(),
EventCode = domain.EventId.Value, EventCode = domain.EventId.Value,
DetectedAt = domain.DetectedAt.ToString("O"), DetectedAt = SqliteDateText.Key(domain.DetectedAt),
Kind = (int)domain.Kind, Kind = (int)domain.Kind,
Score = domain.Score, Score = domain.Score,
EvidenceJson = domain.EvidenceJson, EvidenceJson = domain.EvidenceJson,
@@ -137,7 +135,7 @@ internal static class Mapping
new( new(
Id: Guid.Parse(entity.Id), Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode), EventId: new EventId(entity.EventCode),
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles), DetectedAt: SqliteDateText.Parse(entity.DetectedAt),
Kind: (AnomalyKind)entity.Kind, Kind: (AnomalyKind)entity.Kind,
Score: entity.Score, Score: entity.Score,
EvidenceJson: entity.EvidenceJson); EvidenceJson: entity.EvidenceJson);
@@ -172,7 +170,7 @@ internal static class Mapping
Value = domain.Selection.Value?.Value, Value = domain.Selection.Value?.Value,
Rate = domain.Selection.Rate.Value, Rate = domain.Selection.Rate.Value,
Stake = domain.Stake, Stake = domain.Stake,
PlacedAt = domain.PlacedAt.ToString("O"), PlacedAt = SqliteDateText.Key(domain.PlacedAt),
Outcome = (int)domain.Outcome, Outcome = (int)domain.Outcome,
Notes = domain.Notes, Notes = domain.Notes,
}; };
@@ -181,7 +179,7 @@ internal static class Mapping
{ {
var scope = entity.Scope switch var scope = entity.Scope switch
{ {
ScopeMatch => (BetScope)MatchScope.Instance, ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value), ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException( _ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"), $"Unknown BetScope discriminator: {entity.Scope}"),
@@ -198,7 +196,7 @@ internal static class Mapping
EventId: new EventId(entity.EventCode), EventId: new EventId(entity.EventCode),
Selection: selection, Selection: selection,
Stake: entity.Stake, Stake: entity.Stake,
PlacedAt: DateTimeOffset.Parse(entity.PlacedAt, CultureInfo.InvariantCulture, RoundtripStyles), PlacedAt: SqliteDateText.Parse(entity.PlacedAt),
Outcome: (BetOutcome)entity.Outcome, Outcome: (BetOutcome)entity.Outcome,
Notes: entity.Notes); Notes: entity.Notes);
} }
@@ -23,6 +23,44 @@ internal sealed class AnomalyRepository : IAnomalyRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
} }
public async Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
{
// Server-side COUNT(*) — the unread-badge hot path must not materialise the
// whole table (with EvidenceJson) just to count. DetectedAt is stored as the
// O-format TEXT key (see SqliteDateText); ">" matches the prior in-memory
// GetUnreadCountAsync semantics (strictly newer than the last-seen marker).
var sinceStr = SqliteDateText.Key(since);
return await _db.Anomalies.AsNoTracking()
.Where(a => a.DetectedAt.CompareTo(sinceStr) > 0)
.CountAsync(ct);
}
public async Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct = default)
{
var q = _db.Anomalies.AsNoTracking();
if (from is { } f)
{
var fromStr = SqliteDateText.Key(f);
q = q.Where(a => a.DetectedAt.CompareTo(fromStr) >= 0);
}
if (to is { } t)
{
var toStr = SqliteDateText.Key(t);
q = q.Where(a => a.DetectedAt.CompareTo(toStr) <= 0);
}
var entities = await q
.OrderByDescending(a => a.DetectedAt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(Anomaly entity, CancellationToken ct = default) public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
{ {
var efEntity = Mapping.ToEntity(entity); var efEntity = Mapping.ToEntity(entity);
@@ -26,9 +26,10 @@ internal sealed class EventRepository : IEventRepository
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default) public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{ {
// ScheduledAt is stored as ISO 8601 TEXT; SQLite TEXT comparison sorts correctly for ISO 8601. // ScheduledAt is stored as ISO 8601 TEXT (see SqliteDateText); SQLite TEXT
var fromStr = range.From.ToString("O"); // comparison sorts chronologically for the fixed-offset O format.
var toStr = range.To.ToString("O"); var fromStr = SqliteDateText.Key(range.From);
var toStr = SqliteDateText.Key(range.To);
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can // EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
// translate the relational operators on string columns (which use BINARY/ordinal // translate the relational operators on string columns (which use BINARY/ordinal
@@ -41,6 +42,57 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
} }
public async Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(query);
var fromStr = SqliteDateText.Key(query.Dates.From);
var toStr = SqliteDateText.Key(query.Dates.To);
// Date range + sport filter pushed to SQL so a multi-sport page no longer
// materialises every event in the window. The composite
// IX_Events_SportCode_ScheduledAt index covers this predicate. Case-sensitive
// search / country filtering and locale-aware sorting stay in the service
// layer where Cyrillic ordinal semantics are preserved.
var q = _db.Events.AsNoTracking()
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
&& e.ScheduledAt.CompareTo(toStr) <= 0);
if (query.SportCodes is { Count: > 0 } sports)
{
var sportArray = sports.Distinct().ToArray();
q = q.Where(e => sportArray.Contains(e.SportCode));
}
var entities = await q.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ids);
var result = new Dictionary<EventId, Event>(ids.Count);
if (ids.Count == 0)
return result;
var codes = ids.Select(e => e.Value).Distinct().ToArray();
var entities = await _db.Events.AsNoTracking()
.Where(e => codes.Contains(e.EventCode))
.ToListAsync(ct);
foreach (var entity in entities)
{
var domain = Mapping.ToDomain(entity);
result[domain.Id] = domain;
}
return result;
}
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default) public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
{ {
var entities = await _db.Events.AsNoTracking() var entities = await _db.Events.AsNoTracking()
@@ -50,6 +102,9 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
} }
public Task<int> CountAsync(CancellationToken ct = default) =>
_db.Events.AsNoTracking().CountAsync(ct);
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default) public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
{ {
var codes = await _db.Events.AsNoTracking() var codes = await _db.Events.AsNoTracking()
@@ -23,6 +23,31 @@ internal sealed class ResultRepository : IResultRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
} }
public async Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ids);
var result = new Dictionary<EventId, EventResult>(ids.Count);
if (ids.Count == 0)
return result;
var codes = ids.Select(e => e.Value).Distinct().ToArray();
var entities = await _db.EventResults.AsNoTracking()
.Where(r => codes.Contains(r.EventCode))
.ToListAsync(ct);
foreach (var entity in entities)
{
var domain = Mapping.ToDomain(entity);
result[domain.EventId] = domain;
}
return result;
}
public async Task AddAsync(EventResult entity, CancellationToken ct = default) public async Task AddAsync(EventResult entity, CancellationToken ct = default)
{ {
var efEntity = Mapping.ToEntity(entity); var efEntity = Mapping.ToEntity(entity);
@@ -19,14 +19,22 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
} }
public Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
{
var sinceStr = SqliteDateText.Key(since);
return _db.Snapshots.AsNoTracking()
.Where(s => s.CapturedAt.CompareTo(sinceStr) >= 0)
.CountAsync(ct);
}
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync( public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId, EventId eventId,
DateTimeOffset from, DateTimeOffset from,
DateTimeOffset to, DateTimeOffset to,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var fromStr = from.ToString("O"); var fromStr = SqliteDateText.Key(from);
var toStr = to.ToString("O"); var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking() var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets) .Include(s => s.Bets)
@@ -51,8 +59,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return result; return result;
var ids = eventIds.Select(e => e.Value).Distinct().ToArray(); var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
var fromStr = from.ToString("O"); var fromStr = SqliteDateText.Key(from);
var toStr = to.ToString("O"); var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking() var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets) .Include(s => s.Bets)
@@ -93,7 +101,7 @@ internal sealed class SnapshotRepository : ISnapshotRepository
// expression EF-translatable (the IL would otherwise carry a cast). // expression EF-translatable (the IL would otherwise carry a cast).
const int preMatchSource = (int)Marathon.Domain.Enums.OddsSource.PreMatch; const int preMatchSource = (int)Marathon.Domain.Enums.OddsSource.PreMatch;
var toStr = atOrBefore.ToString("O"); var toStr = SqliteDateText.Key(atOrBefore);
var entity = await _db.Snapshots.AsNoTracking() var entity = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets) .Include(s => s.Bets)
@@ -0,0 +1,42 @@
using System.Globalization;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Single source of truth for how <see cref="DateTimeOffset"/> values are encoded
/// as the TEXT used by both the write path (<see cref="Mapping"/>) and the
/// date-range predicates / ORDER BY clauses in the repositories.
/// </summary>
/// <remarks>
/// <para>
/// Dates are stored as round-trip ISO-8601 (<c>"O"</c> format) TEXT. SQLite TEXT
/// columns use BINARY (ordinal) collation by default, so the relational operators
/// (<c>&gt;=</c>, <c>&lt;=</c>) and <c>ORDER BY</c> on these strings sort
/// <b>chronologically</b> — but ONLY because every persisted timestamp carries the
/// same Moscow <c>+03:00</c> offset (see the project invariant in CLAUDE.md). Two
/// instants written with different offsets would sort lexically, not
/// chronologically, and silently corrupt range filtering.
/// </para>
/// <para>
/// Centralising the format here means the write encoding and the query-bound
/// encoding can never drift apart, and the offset invariant is documented in one
/// authoritative place. If a future change normalises storage to UTC or a native
/// DATETIME column, this is the only call site that must change.
/// </para>
/// </remarks>
internal static class SqliteDateText
{
// Parse with the invariant culture + RoundtripKind so a non-en-US thread
// culture (or a future locale change) cannot corrupt the round-trip.
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
/// <summary>
/// Encodes a <see cref="DateTimeOffset"/> as the TEXT key used for storage and
/// for the bounds of range/ordering predicates.
/// </summary>
public static string Key(DateTimeOffset value) => value.ToString("O");
/// <summary>Decodes a stored TEXT key back into a <see cref="DateTimeOffset"/>.</summary>
public static DateTimeOffset Parse(string text) =>
DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, RoundtripStyles);
}
@@ -28,8 +28,8 @@ internal sealed class LiveOddsPoller : BackgroundService
ILogger<LiveOddsPoller> logger) ILogger<LiveOddsPoller> logger)
{ {
_services = services ?? throw new ArgumentNullException(nameof(services)); _services = services ?? throw new ArgumentNullException(nameof(services));
_opts = opts ?? throw new ArgumentNullException(nameof(opts)); _opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -47,6 +47,8 @@ internal sealed class LiveOddsPoller : BackgroundService
continue; continue;
} }
var cycleStart = DateTime.UtcNow;
try try
{ {
await using var scope = _services.CreateAsyncScope(); await using var scope = _services.CreateAsyncScope();
@@ -69,9 +71,17 @@ internal sealed class LiveOddsPoller : BackgroundService
var interval = TimeSpan.FromSeconds( var interval = TimeSpan.FromSeconds(
Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds)); Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds));
// Budget the sleep against the time the cycle already consumed so the
// effective cadence tracks the configured interval instead of
// (interval + scrapeDuration). If a cycle overran the interval, loop
// immediately rather than sleeping a full extra interval.
var remaining = interval - (DateTime.UtcNow - cycleStart);
if (remaining <= TimeSpan.Zero)
continue;
try try
{ {
await Task.Delay(interval, stoppingToken); await Task.Delay(remaining, stoppingToken);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@@ -28,10 +28,12 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
{ {
ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(filter);
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false); // Date filter pushed to SQL; severity needs the parsed score and sport needs
// the event join, so those two stay in memory over the smaller returned set.
var all = await _anomalies.ListByDateRangeAsync(filter.From, filter.To, ct).ConfigureAwait(false);
if (all.Count == 0) return Array.Empty<AnomalyListItem>(); if (all.Count == 0) return Array.Empty<AnomalyListItem>();
// Resolve event metadata in one pass — distinct EventIds only. // Resolve event metadata in one batched pass — distinct EventIds only.
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false); var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
var items = new List<AnomalyListItem>(all.Count); var items = new List<AnomalyListItem>(all.Count);
@@ -44,7 +46,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
} }
} }
// Apply filters in-memory (small list, UI page). // Remaining filters in-memory (page-sized set).
IEnumerable<AnomalyListItem> filtered = items; IEnumerable<AnomalyListItem> filtered = items;
if (filter.MinSeverity is { } minSeverity) if (filter.MinSeverity is { } minSeverity)
@@ -57,16 +59,6 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
filtered = filtered.Where(i => sports.Contains(i.Sport.Value)); filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
} }
if (filter.From is { } from)
{
filtered = filtered.Where(i => i.DetectedAt >= from);
}
if (filter.To is { } to)
{
filtered = filtered.Where(i => i.DetectedAt <= to);
}
return filtered return filtered
.OrderByDescending(static i => i.DetectedAt) .OrderByDescending(static i => i.DetectedAt)
.ToList(); .ToList();
@@ -88,16 +80,9 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
return new AnomalyDetailVm(item, pre, post); return new AnomalyDetailVm(item, pre, post);
} }
public async Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct) public Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
{ // Server-side COUNT(*) — no longer materialises the table to count.
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false); => _anomalies.CountSinceAsync(since, ct);
var count = 0;
foreach (var anomaly in all)
{
if (anomaly.DetectedAt > since) count++;
}
return count;
}
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct) public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
{ {
@@ -125,14 +110,8 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
.Distinct() .Distinct()
.ToList(); .ToList();
var dict = new Dictionary<DomainEventId, Event>(distinct.Count); // Single batched query instead of one GetAsync per distinct event (N+1).
foreach (var eid in distinct) return await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
{
ct.ThrowIfCancellationRequested();
var ev = await _events.GetAsync(eid, ct).ConfigureAwait(false);
if (ev is not null) dict[eid] = ev;
}
return dict;
} }
private static bool TryProject( private static bool TryProject(
@@ -151,7 +130,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
var country = ev?.CountryCode ?? string.Empty; var country = ev?.CountryCode ?? string.Empty;
var league = ev?.LeagueId ?? string.Empty; var league = ev?.LeagueId ?? string.Empty;
var title = ev is not null var title = ev is not null
? $"{ev.Side1Name} vs {ev.Side2Name}" ? ev.Title
: anomaly.EventId.Value; : anomaly.EventId.Value;
var preSnap = ToSnapshot(dto.PreSuspension); var preSnap = ToSnapshot(dto.PreSuspension);
@@ -40,15 +40,15 @@ public sealed class BetJournalService : IBetJournalService
if (report.Bets.Count == 0) if (report.Bets.Count == 0)
return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>()); return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>());
// Resolve event titles in one pass — distinct ids only. // Resolve event titles in one batched query — distinct ids only (was N+1).
// Missing events (pruned by snapshot retention) fall back to the raw id.
var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList(); var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList();
var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(distinctIds.Count); var titles = new Dictionary<DomainEventId, string>(distinctIds.Count);
foreach (var id in distinctIds) foreach (var id in distinctIds)
{ {
ct.ThrowIfCancellationRequested(); titles[id] = events.TryGetValue(id, out var ev)
var ev = await _events.GetAsync(id, ct).ConfigureAwait(false); ? ev.Title
titles[id] = ev is not null
? string.Concat(ev.Side1Name, " vs ", ev.Side2Name)
: id.Value; : id.Value;
} }
@@ -83,15 +83,17 @@ public sealed class EventBrowsingService : IEventBrowsingService
{ {
ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(filter);
var range = new DateRange(filter.Dates.From, filter.Dates.To); // Date range + sport filter pushed to SQL (was: load the whole date range,
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false); // then filter sports in memory). Country/search filtering and locale-aware
// sorting stay here to preserve the Cyrillic ordinal semantics that SQLite's
// BINARY collation would change.
var query = new EventQuery(
new DateRange(filter.Dates.From, filter.Dates.To),
filter.SportCodes);
var events = await _events.QueryAsync(query, ct).ConfigureAwait(false);
// Apply non-temporal filters in-memory — list size is small (UI page).
IEnumerable<Event> filtered = events; IEnumerable<Event> filtered = events;
if (filter.SportCodes is { Count: > 0 } sports)
filtered = filtered.Where(e => sports.Contains(e.Sport.Value));
if (filter.CountryCodes is { Count: > 0 } countries) if (filter.CountryCodes is { Count: > 0 } countries)
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase)); filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
@@ -19,6 +19,12 @@ public sealed class BuildBetJournalReportUseCaseTests
private readonly IEventRepository _events = Substitute.For<IEventRepository>(); private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly ISnapshotRepository _snapshots = Substitute.For<ISnapshotRepository>(); private readonly ISnapshotRepository _snapshots = Substitute.For<ISnapshotRepository>();
public BuildBetJournalReportUseCaseTests()
{
// Use case batches event loads via GetManyAsync; route through per-id stubs.
TestFixtures.BridgeGetMany(_events);
}
private BuildBetJournalReportUseCase CreateSut() => private BuildBetJournalReportUseCase CreateSut() =>
new(_bets, _events, _snapshots, NullLogger<BuildBetJournalReportUseCase>.Instance); new(_bets, _events, _snapshots, NullLogger<BuildBetJournalReportUseCase>.Instance);
@@ -22,6 +22,14 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests
private readonly IEventRepository _events = Substitute.For<IEventRepository>(); private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>(); private readonly IResultRepository _results = Substitute.For<IResultRepository>();
public EvaluateAnomalyOutcomesUseCaseTests()
{
// Use cases batch event/result loads via GetManyAsync; route those through
// the per-id GetAsync stubs each test already configures.
TestFixtures.BridgeGetMany(_events);
TestFixtures.BridgeGetMany(_results);
}
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime = private static readonly DateTimeOffset BaseTime =
new(2026, 5, 10, 18, 0, 0, MoscowOffset); new(2026, 5, 10, 18, 0, 0, MoscowOffset);
@@ -287,6 +295,36 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests
report.EventTitles[id].Should().Be("Team A vs Team B"); report.EventTitles[id].Should().Be("Team A vs Team B");
} }
[Fact]
public async Task Should_BatchEventAndResultLoads_InsteadOfPerIdGetAsync()
{
// Regression guard for the N+1 fix: the use case must resolve events/results
// via the batched GetManyAsync, never the per-id GetAsync in a loop. We stub
// GetManyAsync directly (overriding the constructor bridge) so DidNotReceive()
// on GetAsync is meaningful.
var id1 = new EventId("11111111");
var id2 = new EventId("22222222");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id1, 0.55m), MakeAnomaly(id2, 0.55m) }.ToList().AsReadOnly());
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<EventId, Event> { [id1] = MakeEvent(id1, 11), [id2] = MakeEvent(id2, 6) });
_results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<EventId, EventResult>());
await CreateSut().ExecuteAsync(CancellationToken.None);
await _events.Received(1)
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
await _events.DidNotReceive()
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
await _results.Received(1)
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
await _results.DidNotReceive()
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
}
[Fact] [Fact]
public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets() public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets()
{ {
@@ -17,6 +17,13 @@ public sealed class PullResultsUseCaseTests
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>(); private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>(); private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
public PullResultsUseCaseTests()
{
// Selection-mode candidate resolution now batches via GetManyAsync; route
// it through the per-id GetAsync stubs each test configures.
TestFixtures.BridgeGetMany(_eventRepo);
}
private static readonly DateRange AnyRange = new( private static readonly DateRange AnyRange = new(
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow); DateTimeOffset.UtcNow);
@@ -25,6 +25,13 @@ public sealed class RunBacktestUseCaseTests
private readonly IEventRepository _events = Substitute.For<IEventRepository>(); private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>(); private readonly IResultRepository _results = Substitute.For<IResultRepository>();
public RunBacktestUseCaseTests()
{
// Use case batches event/result loads via GetManyAsync; route through per-id stubs.
TestFixtures.BridgeGetMany(_events);
TestFixtures.BridgeGetMany(_results);
}
private RunBacktestUseCase CreateSut() => private RunBacktestUseCase CreateSut() =>
new(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.Instance); new(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.Instance);
@@ -1,8 +1,10 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Configuration; using Marathon.Application.Configuration;
using Marathon.Domain.Entities; using Marathon.Domain.Entities;
using Marathon.Domain.Enums; using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects; using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases; namespace Marathon.Application.Tests.UseCases;
@@ -13,6 +15,45 @@ internal static class TestFixtures
{ {
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
/// <summary>
/// Bridges the legacy per-id <c>GetAsync</c> stubs to the batched
/// <c>GetManyAsync</c> the use cases now call: each requested id is resolved
/// through whatever <c>GetAsync</c> was configured to return for it. Lets the
/// existing per-id <c>.Returns(...)</c> setups keep working unchanged.
/// </summary>
public static void BridgeGetMany(IEventRepository events)
{
events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
var dict = new Dictionary<EventId, Event>();
foreach (var id in ids.Distinct())
{
var ev = events.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
if (ev is not null) dict[id] = ev;
}
return (IReadOnlyDictionary<EventId, Event>)dict;
});
}
/// <inheritdoc cref="BridgeGetMany(IEventRepository)"/>
public static void BridgeGetMany(IResultRepository results)
{
results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
var dict = new Dictionary<EventId, EventResult>();
foreach (var id in ids.Distinct())
{
var r = results.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
if (r is not null) dict[id] = r;
}
return (IReadOnlyDictionary<EventId, EventResult>)dict;
});
}
/// <summary>Creates a minimal valid <see cref="Event"/> with the given event ID string.</summary> /// <summary>Creates a minimal valid <see cref="Event"/> with the given event ID string.</summary>
public static Event MakeEvent(string eventIdValue = "12345678") public static Event MakeEvent(string eventIdValue = "12345678")
{ {
@@ -32,6 +32,21 @@ public sealed class ResultsLoaderTests : MarathonTestContext
sp.GetRequiredService<IEventRepository>(), sp.GetRequiredService<IEventRepository>(),
sp.GetRequiredService<IResultRepository>(), sp.GetRequiredService<IResultRepository>(),
NullLogger<PullResultsUseCase>.Instance)); NullLogger<PullResultsUseCase>.Instance));
// PullResultsUseCase batches selection-mode candidate resolution via
// GetManyAsync; route it through whatever GetAsync the test configures.
_eventRepo.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
var dict = new Dictionary<EventId, Event>();
foreach (var id in ids.Distinct())
{
var ev = _eventRepo.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
if (ev is not null) dict[id] = ev;
}
return (IReadOnlyDictionary<EventId, Event>)dict;
});
} }
[Fact] [Fact]