diff --git a/src/Marathon.Application/Abstractions/IAnomalyRepository.cs b/src/Marathon.Application/Abstractions/IAnomalyRepository.cs
index 626b611..d8a47f3 100644
--- a/src/Marathon.Application/Abstractions/IAnomalyRepository.cs
+++ b/src/Marathon.Application/Abstractions/IAnomalyRepository.cs
@@ -5,4 +5,22 @@ namespace Marathon.Application.Abstractions;
///
/// Repository for domain entities.
///
-public interface IAnomalyRepository : IRepository;
+public interface IAnomalyRepository : IRepository
+{
+ ///
+ /// Server-side count of anomalies detected strictly after .
+ /// Backs the unread badge without materialising the table.
+ ///
+ Task CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
+
+ ///
+ /// Anomalies whose falls in the inclusive
+ /// [..] 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).
+ ///
+ Task> ListByDateRangeAsync(
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ CancellationToken ct = default);
+}
diff --git a/src/Marathon.Application/Abstractions/IEventRepository.cs b/src/Marathon.Application/Abstractions/IEventRepository.cs
index fc99e10..29f4768 100644
--- a/src/Marathon.Application/Abstractions/IEventRepository.cs
+++ b/src/Marathon.Application/Abstractions/IEventRepository.cs
@@ -11,8 +11,27 @@ public interface IEventRepository : IRepository
{
Task> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
+ ///
+ /// 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.
+ ///
+ Task> QueryAsync(EventQuery query, CancellationToken ct = default);
+
+ ///
+ /// Batched point-lookup: loads many events in a single query, keyed by
+ /// . Missing ids are simply absent from the dictionary.
+ /// Replaces per-id loops (N+1).
+ ///
+ Task> GetManyAsync(
+ IReadOnlyCollection ids,
+ CancellationToken ct = default);
+
Task> ListBySportAsync(SportCode sport, CancellationToken ct = default);
+ /// Server-side total event count (dashboard summary).
+ Task CountAsync(CancellationToken ct = default);
+
///
/// Distinct sport codes across the events table. Projects in the database
/// rather than materialising every on the client.
diff --git a/src/Marathon.Application/Abstractions/IResultRepository.cs b/src/Marathon.Application/Abstractions/IResultRepository.cs
index a4abf78..45b2579 100644
--- a/src/Marathon.Application/Abstractions/IResultRepository.cs
+++ b/src/Marathon.Application/Abstractions/IResultRepository.cs
@@ -6,4 +6,14 @@ namespace Marathon.Application.Abstractions;
///
/// Repository for domain entities.
///
-public interface IResultRepository : IRepository;
+public interface IResultRepository : IRepository
+{
+ ///
+ /// Batched point-lookup: loads many results in a single query, keyed by
+ /// . Missing ids are simply absent from the dictionary.
+ /// Replaces per-id loops (N+1).
+ ///
+ Task> GetManyAsync(
+ IReadOnlyCollection ids,
+ CancellationToken ct = default);
+}
diff --git a/src/Marathon.Application/Abstractions/ISnapshotRepository.cs b/src/Marathon.Application/Abstractions/ISnapshotRepository.cs
index 55bca2a..a22bdbd 100644
--- a/src/Marathon.Application/Abstractions/ISnapshotRepository.cs
+++ b/src/Marathon.Application/Abstractions/ISnapshotRepository.cs
@@ -16,6 +16,12 @@ public interface ISnapshotRepository
{
Task> ListAsync(CancellationToken ct = default);
+ ///
+ /// Server-side count of snapshots captured at or after .
+ /// Backs the dashboard "snapshots today" stat without materialising rows.
+ ///
+ Task CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
+
Task> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
diff --git a/src/Marathon.Application/Storage/EventQuery.cs b/src/Marathon.Application/Storage/EventQuery.cs
new file mode 100644
index 0000000..ec803c8
--- /dev/null
+++ b/src/Marathon.Application/Storage/EventQuery.cs
@@ -0,0 +1,13 @@
+namespace Marathon.Application.Storage;
+
+///
+/// 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).
+///
+/// Inclusive scheduled-at window.
+/// When non-empty, restricts to these sport codes. Null/empty = all sports.
+public sealed record EventQuery(
+ DateRange Dates,
+ IReadOnlyCollection? SportCodes = null);
diff --git a/src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs b/src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs
index 0b74886..aacb4fa 100644
--- a/src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs
+++ b/src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs
@@ -54,16 +54,17 @@ public sealed class BuildBetJournalReportUseCase
var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList();
- // Resolve closing snapshot per event using a single-row repo call —
- // pushes the ORDER BY / LIMIT 1 down to SQLite rather than materialising
- // every snapshot in a 30-day window.
+ // Batch the event loads (was N+1). The closing-snapshot lookup stays per-event
+ // because it pushes ORDER BY / LIMIT 1 down to SQLite (one indexed row each)
+ // and is parameterised by that event's ScheduledAt.
+ var events = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
+
var closingByEvent = new Dictionary(distinctEventIds.Count);
foreach (var eventId in distinctEventIds)
{
ct.ThrowIfCancellationRequested();
- var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false);
- if (ev is null)
+ if (!events.TryGetValue(eventId, out var ev))
{
closingByEvent[eventId] = null;
continue;
diff --git a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs
index 1e420c7..28465c1 100644
--- a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs
+++ b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs
@@ -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 _logger;
public DetectAnomaliesUseCase(
@@ -43,11 +43,11 @@ public sealed class DetectAnomaliesUseCase
IOptions options,
ILogger 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));
}
///
@@ -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();
- newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, ct);
+ var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
+ ? slice
+ : new List();
+ newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingForEvent, ct);
}
catch (OperationCanceledException)
{
@@ -117,7 +123,7 @@ public sealed class DetectAnomaliesUseCase
AnomalyDetector detector,
Event ev,
IReadOnlyList snapshots,
- IReadOnlyList existingAnomalies,
+ List 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);
}
diff --git a/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs b/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs
index e44c050..eac54cd 100644
--- a/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs
+++ b/src/Marathon.Application/UseCases/EvaluateAnomalyOutcomesUseCase.cs
@@ -75,29 +75,16 @@ public sealed class EvaluateAnomalyOutcomesUseCase
return EmptyReport();
}
- // Build event + result lookups — distinct keys only to avoid quadratic loads.
- // TODO (perf, future): batch via IEventRepository.GetManyAsync / IResultRepository.GetManyAsync
- // 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).
+ // Batched lookups — a single query each, replacing the prior per-event
+ // GetAsync round-trip (N+1 against SQLite).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
- var eventLookup = new Dictionary(distinctEventIds.Count);
- var resultLookup = new Dictionary(distinctEventIds.Count);
- var eventTitles = new Dictionary(distinctEventIds.Count);
- foreach (var id in distinctEventIds)
- {
- ct.ThrowIfCancellationRequested();
+ var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
+ var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
- var ev = await _events.GetAsync(id, ct).ConfigureAwait(false);
- if (ev is not null)
- {
- 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;
- }
+ var eventTitles = new Dictionary(eventLookup.Count);
+ foreach (var (id, ev) in eventLookup)
+ eventTitles[id] = ev.Title;
// Evaluate every anomaly through the pure domain function.
var resolved = new List();
diff --git a/src/Marathon.Application/UseCases/PullResultsUseCase.cs b/src/Marathon.Application/UseCases/PullResultsUseCase.cs
index 754d933..cb804a2 100644
--- a/src/Marathon.Application/UseCases/PullResultsUseCase.cs
+++ b/src/Marathon.Application/UseCases/PullResultsUseCase.cs
@@ -72,10 +72,10 @@ public sealed class PullResultsUseCase
IResultRepository resultRepo,
ILogger logger)
{
- _scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
- _eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
+ _scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
+ _eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
@@ -149,12 +149,13 @@ public sealed class PullResultsUseCase
{
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(selection.Count);
foreach (var id in selection)
{
- ct.ThrowIfCancellationRequested();
- var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false);
- if (ev is not null)
+ if (events.TryGetValue(id, out var ev))
resolved.Add(ev);
}
return resolved;
diff --git a/src/Marathon.Application/UseCases/RunBacktestUseCase.cs b/src/Marathon.Application/UseCases/RunBacktestUseCase.cs
index 3fe3177..75247de 100644
--- a/src/Marathon.Application/UseCases/RunBacktestUseCase.cs
+++ b/src/Marathon.Application/UseCases/RunBacktestUseCase.cs
@@ -63,29 +63,16 @@ public sealed class RunBacktestUseCase
return BacktestSimulator.Run(strategy, Array.Empty());
}
- // Distinct event lookups — minimises repo calls.
- // TODO (perf, future): batch via IEventRepository.GetManyAsync /
- // IResultRepository.GetManyAsync once those exist — currently shared
- // with EvaluateAnomalyOutcomesUseCase, acceptable at expected volumes.
+ // Batched lookups — a single query each, replacing the prior per-event
+ // GetAsync round-trip (N+1 against SQLite).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
- var eventLookup = new Dictionary(distinctEventIds.Count);
- var resultLookup = new Dictionary(distinctEventIds.Count);
- var titles = new Dictionary(distinctEventIds.Count);
- foreach (var id in distinctEventIds)
- {
- ct.ThrowIfCancellationRequested();
+ var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
+ var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
- var ev = await _events.GetAsync(id, ct).ConfigureAwait(false);
- if (ev is not null)
- {
- 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 titles = new Dictionary(eventLookup.Count);
+ foreach (var (id, ev) in eventLookup)
+ titles[id] = ev.Title;
var candidates = new List(anomalies.Count);
foreach (var anomaly in anomalies)
diff --git a/src/Marathon.Domain/Entities/Event.cs b/src/Marathon.Domain/Entities/Event.cs
index f1ac593..2ad6651 100644
--- a/src/Marathon.Domain/Entities/Event.cs
+++ b/src/Marathon.Domain/Entities/Event.cs
@@ -63,4 +63,11 @@ public sealed record Event(
/// numeric event ID.
///
public string? EventPath { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public string Title => $"{Side1Name} vs {Side2Name}";
}
diff --git a/src/Marathon.Infrastructure/Migrations/20260528000000_AddSnapshotCapturedAtIndexes.cs b/src/Marathon.Infrastructure/Migrations/20260528000000_AddSnapshotCapturedAtIndexes.cs
new file mode 100644
index 0000000..a21c018
--- /dev/null
+++ b/src/Marathon.Infrastructure/Migrations/20260528000000_AddSnapshotCapturedAtIndexes.cs
@@ -0,0 +1,43 @@
+using Marathon.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Marathon.Infrastructure.Migrations;
+
+///
+[DbContext(typeof(MarathonDbContext))]
+[Migration("20260528000000_AddSnapshotCapturedAtIndexes")]
+public partial class AddSnapshotCapturedAtIndexes : Migration
+{
+ ///
+ 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" });
+ }
+
+ ///
+ 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");
+ }
+}
diff --git a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs
index dc4e7af..68bbcdf 100644
--- a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs
+++ b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs
@@ -92,6 +92,8 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
b.Property("Source").HasColumnType("INTEGER");
b.HasKey("Id");
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");
});
diff --git a/src/Marathon.Infrastructure/Persistence/Configurations/SnapshotConfiguration.cs b/src/Marathon.Infrastructure/Persistence/Configurations/SnapshotConfiguration.cs
index 4b153b7..fa188ac 100644
--- a/src/Marathon.Infrastructure/Persistence/Configurations/SnapshotConfiguration.cs
+++ b/src/Marathon.Infrastructure/Persistence/Configurations/SnapshotConfiguration.cs
@@ -18,6 +18,17 @@ internal sealed class SnapshotConfiguration : IEntityTypeConfiguration s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
+ // Snapshots is the largest table (live cadence 5–10s, 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)
.WithOne(b => b.Snapshot)
.HasForeignKey(b => b.SnapshotId)
diff --git a/src/Marathon.Infrastructure/Persistence/Mapping.cs b/src/Marathon.Infrastructure/Persistence/Mapping.cs
index b119452..36a6145 100644
--- a/src/Marathon.Infrastructure/Persistence/Mapping.cs
+++ b/src/Marathon.Infrastructure/Persistence/Mapping.cs
@@ -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.
///
+///
+/// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and
+/// decoded exclusively through so the write format and
+/// the repositories' range-predicate format can never drift apart.
+///
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);
}
diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/AnomalyRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/AnomalyRepository.cs
index 838cac5..9ae2a97 100644
--- a/src/Marathon.Infrastructure/Persistence/Repositories/AnomalyRepository.cs
+++ b/src/Marathon.Infrastructure/Persistence/Repositories/AnomalyRepository.cs
@@ -23,6 +23,44 @@ internal sealed class AnomalyRepository : IAnomalyRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
+ public async Task 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> 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)
{
var efEntity = Mapping.ToEntity(entity);
diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs
index 9167c10..0279646 100644
--- a/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs
+++ b/src/Marathon.Infrastructure/Persistence/Repositories/EventRepository.cs
@@ -26,9 +26,10 @@ internal sealed class EventRepository : IEventRepository
public async Task> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{
- // ScheduledAt is stored as ISO 8601 TEXT; SQLite TEXT comparison sorts correctly for ISO 8601.
- var fromStr = range.From.ToString("O");
- var toStr = range.To.ToString("O");
+ // ScheduledAt is stored as ISO 8601 TEXT (see SqliteDateText); SQLite TEXT
+ // comparison sorts chronologically for the fixed-offset O format.
+ var fromStr = SqliteDateText.Key(range.From);
+ var toStr = SqliteDateText.Key(range.To);
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
// 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();
}
+ public async Task> 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> GetManyAsync(
+ IReadOnlyCollection ids,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(ids);
+
+ var result = new Dictionary(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> ListBySportAsync(SportCode sport, CancellationToken ct = default)
{
var entities = await _db.Events.AsNoTracking()
@@ -50,6 +102,9 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
+ public Task CountAsync(CancellationToken ct = default) =>
+ _db.Events.AsNoTracking().CountAsync(ct);
+
public async Task> ListDistinctSportCodesAsync(CancellationToken ct = default)
{
var codes = await _db.Events.AsNoTracking()
diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/ResultRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/ResultRepository.cs
index f705154..cf33247 100644
--- a/src/Marathon.Infrastructure/Persistence/Repositories/ResultRepository.cs
+++ b/src/Marathon.Infrastructure/Persistence/Repositories/ResultRepository.cs
@@ -23,6 +23,31 @@ internal sealed class ResultRepository : IResultRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
+ public async Task> GetManyAsync(
+ IReadOnlyCollection ids,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(ids);
+
+ var result = new Dictionary(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)
{
var efEntity = Mapping.ToEntity(entity);
diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs
index 1374850..17395c5 100644
--- a/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs
+++ b/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs
@@ -19,14 +19,22 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
+ public Task 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> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default)
{
- var fromStr = from.ToString("O");
- var toStr = to.ToString("O");
+ var fromStr = SqliteDateText.Key(from);
+ var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
@@ -51,8 +59,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return result;
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
- var fromStr = from.ToString("O");
- var toStr = to.ToString("O");
+ var fromStr = SqliteDateText.Key(from);
+ var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
@@ -93,7 +101,7 @@ internal sealed class SnapshotRepository : ISnapshotRepository
// expression EF-translatable (the IL would otherwise carry a cast).
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()
.Include(s => s.Bets)
diff --git a/src/Marathon.Infrastructure/Persistence/SqliteDateText.cs b/src/Marathon.Infrastructure/Persistence/SqliteDateText.cs
new file mode 100644
index 0000000..2761d22
--- /dev/null
+++ b/src/Marathon.Infrastructure/Persistence/SqliteDateText.cs
@@ -0,0 +1,42 @@
+using System.Globalization;
+
+namespace Marathon.Infrastructure.Persistence;
+
+///
+/// Single source of truth for how values are encoded
+/// as the TEXT used by both the write path () and the
+/// date-range predicates / ORDER BY clauses in the repositories.
+///
+///
+///
+/// Dates are stored as round-trip ISO-8601 ("O" format) TEXT. SQLite TEXT
+/// columns use BINARY (ordinal) collation by default, so the relational operators
+/// (>=, <=) and ORDER BY on these strings sort
+/// chronologically — but ONLY because every persisted timestamp carries the
+/// same Moscow +03:00 offset (see the project invariant in CLAUDE.md). Two
+/// instants written with different offsets would sort lexically, not
+/// chronologically, and silently corrupt range filtering.
+///
+///
+/// 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.
+///
+///
+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;
+
+ ///
+ /// Encodes a as the TEXT key used for storage and
+ /// for the bounds of range/ordering predicates.
+ ///
+ public static string Key(DateTimeOffset value) => value.ToString("O");
+
+ /// Decodes a stored TEXT key back into a .
+ public static DateTimeOffset Parse(string text) =>
+ DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, RoundtripStyles);
+}
diff --git a/src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs b/src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs
index 55a19f5..e300c90 100644
--- a/src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs
+++ b/src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs
@@ -28,8 +28,8 @@ internal sealed class LiveOddsPoller : BackgroundService
ILogger logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
- _opts = opts ?? throw new ArgumentNullException(nameof(opts));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _opts = opts ?? throw new ArgumentNullException(nameof(opts));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -47,6 +47,8 @@ internal sealed class LiveOddsPoller : BackgroundService
continue;
}
+ var cycleStart = DateTime.UtcNow;
+
try
{
await using var scope = _services.CreateAsyncScope();
@@ -69,9 +71,17 @@ internal sealed class LiveOddsPoller : BackgroundService
var interval = TimeSpan.FromSeconds(
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
{
- await Task.Delay(interval, stoppingToken);
+ await Task.Delay(remaining, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
diff --git a/src/Marathon.UI/Services/AnomalyBrowsingService.cs b/src/Marathon.UI/Services/AnomalyBrowsingService.cs
index 6ece9ed..7286ce7 100644
--- a/src/Marathon.UI/Services/AnomalyBrowsingService.cs
+++ b/src/Marathon.UI/Services/AnomalyBrowsingService.cs
@@ -28,10 +28,12 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
{
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();
- // 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 items = new List(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 filtered = items;
if (filter.MinSeverity is { } minSeverity)
@@ -57,16 +59,6 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
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
.OrderByDescending(static i => i.DetectedAt)
.ToList();
@@ -88,16 +80,9 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
return new AnomalyDetailVm(item, pre, post);
}
- public async Task GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
- {
- var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
- var count = 0;
- foreach (var anomaly in all)
- {
- if (anomaly.DetectedAt > since) count++;
- }
- return count;
- }
+ public Task GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
+ // Server-side COUNT(*) — no longer materialises the table to count.
+ => _anomalies.CountSinceAsync(since, ct);
public async Task> ListKnownSportCodesAsync(CancellationToken ct)
{
@@ -125,14 +110,8 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
.Distinct()
.ToList();
- var dict = new Dictionary(distinct.Count);
- foreach (var eid in distinct)
- {
- ct.ThrowIfCancellationRequested();
- var ev = await _events.GetAsync(eid, ct).ConfigureAwait(false);
- if (ev is not null) dict[eid] = ev;
- }
- return dict;
+ // Single batched query instead of one GetAsync per distinct event (N+1).
+ return await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
}
private static bool TryProject(
@@ -151,7 +130,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
var country = ev?.CountryCode ?? string.Empty;
var league = ev?.LeagueId ?? string.Empty;
var title = ev is not null
- ? $"{ev.Side1Name} vs {ev.Side2Name}"
+ ? ev.Title
: anomaly.EventId.Value;
var preSnap = ToSnapshot(dto.PreSuspension);
diff --git a/src/Marathon.UI/Services/BetJournalService.cs b/src/Marathon.UI/Services/BetJournalService.cs
index ec32798..267f788 100644
--- a/src/Marathon.UI/Services/BetJournalService.cs
+++ b/src/Marathon.UI/Services/BetJournalService.cs
@@ -40,15 +40,15 @@ public sealed class BetJournalService : IBetJournalService
if (report.Bets.Count == 0)
return new BetJournalVm(report.Stats, Array.Empty());
- // 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 events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
var titles = new Dictionary(distinctIds.Count);
foreach (var id in distinctIds)
{
- ct.ThrowIfCancellationRequested();
- var ev = await _events.GetAsync(id, ct).ConfigureAwait(false);
- titles[id] = ev is not null
- ? string.Concat(ev.Side1Name, " vs ", ev.Side2Name)
+ titles[id] = events.TryGetValue(id, out var ev)
+ ? ev.Title
: id.Value;
}
diff --git a/src/Marathon.UI/Services/EventBrowsingService.cs b/src/Marathon.UI/Services/EventBrowsingService.cs
index e5a99f4..a3c423f 100644
--- a/src/Marathon.UI/Services/EventBrowsingService.cs
+++ b/src/Marathon.UI/Services/EventBrowsingService.cs
@@ -83,15 +83,17 @@ public sealed class EventBrowsingService : IEventBrowsingService
{
ArgumentNullException.ThrowIfNull(filter);
- var range = new DateRange(filter.Dates.From, filter.Dates.To);
- var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
+ // Date range + sport filter pushed to SQL (was: load the whole date range,
+ // 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 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)
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
diff --git a/tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs
index e3f622e..6bed55e 100644
--- a/tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs
+++ b/tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs
@@ -19,6 +19,12 @@ public sealed class BuildBetJournalReportUseCaseTests
private readonly IEventRepository _events = Substitute.For();
private readonly ISnapshotRepository _snapshots = Substitute.For();
+ public BuildBetJournalReportUseCaseTests()
+ {
+ // Use case batches event loads via GetManyAsync; route through per-id stubs.
+ TestFixtures.BridgeGetMany(_events);
+ }
+
private BuildBetJournalReportUseCase CreateSut() =>
new(_bets, _events, _snapshots, NullLogger.Instance);
diff --git a/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs
index 17bd833..08b7a01 100644
--- a/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs
+++ b/tests/Marathon.Application.Tests/UseCases/EvaluateAnomalyOutcomesUseCaseTests.cs
@@ -22,6 +22,14 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests
private readonly IEventRepository _events = Substitute.For();
private readonly IResultRepository _results = Substitute.For();
+ 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 DateTimeOffset BaseTime =
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");
}
+ [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())
+ .Returns(new[] { MakeAnomaly(id1, 0.55m), MakeAnomaly(id2, 0.55m) }.ToList().AsReadOnly());
+
+ _events.GetManyAsync(Arg.Any>(), Arg.Any())
+ .Returns(new Dictionary { [id1] = MakeEvent(id1, 11), [id2] = MakeEvent(id2, 6) });
+ _results.GetManyAsync(Arg.Any>(), Arg.Any())
+ .Returns(new Dictionary());
+
+ await CreateSut().ExecuteAsync(CancellationToken.None);
+
+ await _events.Received(1)
+ .GetManyAsync(Arg.Any>(), Arg.Any());
+ await _events.DidNotReceive()
+ .GetAsync(Arg.Any(), Arg.Any());
+ await _results.Received(1)
+ .GetManyAsync(Arg.Any>(), Arg.Any());
+ await _results.DidNotReceive()
+ .GetAsync(Arg.Any(), Arg.Any());
+ }
+
[Fact]
public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets()
{
diff --git a/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs
index 36e8d3a..76aaeac 100644
--- a/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs
+++ b/tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs
@@ -17,6 +17,13 @@ public sealed class PullResultsUseCaseTests
private readonly IEventRepository _eventRepo = Substitute.For();
private readonly IResultRepository _resultRepo = Substitute.For();
+ 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(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow);
diff --git a/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs
index 980c294..e1b37c9 100644
--- a/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs
+++ b/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs
@@ -25,6 +25,13 @@ public sealed class RunBacktestUseCaseTests
private readonly IEventRepository _events = Substitute.For();
private readonly IResultRepository _results = Substitute.For();
+ public RunBacktestUseCaseTests()
+ {
+ // Use case batches event/result loads via GetManyAsync; route through per-id stubs.
+ TestFixtures.BridgeGetMany(_events);
+ TestFixtures.BridgeGetMany(_results);
+ }
+
private RunBacktestUseCase CreateSut() =>
new(_anomalies, _events, _results, NullLogger.Instance);
diff --git a/tests/Marathon.Application.Tests/UseCases/TestFixtures.cs b/tests/Marathon.Application.Tests/UseCases/TestFixtures.cs
index ab7922d..4935e11 100644
--- a/tests/Marathon.Application.Tests/UseCases/TestFixtures.cs
+++ b/tests/Marathon.Application.Tests/UseCases/TestFixtures.cs
@@ -1,8 +1,10 @@
+using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Options;
+using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
@@ -13,6 +15,45 @@ internal static class TestFixtures
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
+ ///
+ /// Bridges the legacy per-id GetAsync stubs to the batched
+ /// GetManyAsync the use cases now call: each requested id is resolved
+ /// through whatever GetAsync was configured to return for it. Lets the
+ /// existing per-id .Returns(...) setups keep working unchanged.
+ ///
+ public static void BridgeGetMany(IEventRepository events)
+ {
+ events.GetManyAsync(Arg.Any>(), Arg.Any())
+ .Returns(ci =>
+ {
+ var ids = ci.Arg>();
+ var dict = new Dictionary();
+ 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)dict;
+ });
+ }
+
+ ///
+ public static void BridgeGetMany(IResultRepository results)
+ {
+ results.GetManyAsync(Arg.Any>(), Arg.Any())
+ .Returns(ci =>
+ {
+ var ids = ci.Arg>();
+ var dict = new Dictionary();
+ 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)dict;
+ });
+ }
+
/// Creates a minimal valid with the given event ID string.
public static Event MakeEvent(string eventIdValue = "12345678")
{
diff --git a/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs b/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs
index cf49e47..ec47f8a 100644
--- a/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs
+++ b/tests/Marathon.UI.Tests/Pages/Results/ResultsLoaderTests.cs
@@ -32,6 +32,21 @@ public sealed class ResultsLoaderTests : MarathonTestContext
sp.GetRequiredService(),
sp.GetRequiredService(),
NullLogger.Instance));
+
+ // PullResultsUseCase batches selection-mode candidate resolution via
+ // GetManyAsync; route it through whatever GetAsync the test configures.
+ _eventRepo.GetManyAsync(Arg.Any>(), Arg.Any())
+ .Returns(ci =>
+ {
+ var ids = ci.Arg>();
+ var dict = new Dictionary();
+ 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)dict;
+ });
}
[Fact]