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]