perf: batch repository reads, index snapshots, centralize date encoding
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at 6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing, results selection); guarded by a Received(1).GetManyAsync test. - Add EventRepository.QueryAsync to push date+sport filtering to SQL (was load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order. - Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync (feed date filter); add Event/Snapshot count methods for the dashboard. - Add composite indexes IX_Snapshots_EventCode_CapturedAt and _EventCode_Source_CapturedAt via a new migration + model snapshot. - Introduce SqliteDateText as the single source of the O-format date encoding shared by Mapping (read/write) and the repositories' range predicates. - Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join. Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
This commit is contained in:
@@ -5,4 +5,22 @@ namespace Marathon.Application.Abstractions;
|
||||
/// <summary>
|
||||
/// Repository for <see cref="Anomaly"/> domain entities.
|
||||
/// </summary>
|
||||
public interface IAnomalyRepository : IRepository<Guid, Anomaly>;
|
||||
public interface IAnomalyRepository : IRepository<Guid, Anomaly>
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-side count of anomalies detected strictly after <paramref name="since"/>.
|
||||
/// Backs the unread badge without materialising the table.
|
||||
/// </summary>
|
||||
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Anomalies whose <see cref="Anomaly.DetectedAt"/> falls in the inclusive
|
||||
/// [<paramref name="from"/>..<paramref name="to"/>] window (either bound may be
|
||||
/// null for open-ended), ordered newest-first. Pushes the temporal filter to SQL;
|
||||
/// severity / sport filtering remains a service concern (needs the event join).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,27 @@ public interface IEventRepository : IRepository<EventId, Event>
|
||||
{
|
||||
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Date-range + sport-filtered query pushed to the database. Replaces the
|
||||
/// "load the whole date range then filter sports in memory" path on the list
|
||||
/// pages. Locale-sensitive search and sort remain a service-layer concern.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batched point-lookup: loads many events in a single query, keyed by
|
||||
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
|
||||
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
|
||||
IReadOnlyCollection<EventId> ids,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Server-side total event count (dashboard summary).</summary>
|
||||
Task<int> CountAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct sport codes across the events table. Projects in the database
|
||||
/// rather than materialising every <see cref="Event"/> on the client.
|
||||
|
||||
@@ -6,4 +6,14 @@ namespace Marathon.Application.Abstractions;
|
||||
/// <summary>
|
||||
/// Repository for <see cref="EventResult"/> domain entities.
|
||||
/// </summary>
|
||||
public interface IResultRepository : IRepository<EventId, EventResult>;
|
||||
public interface IResultRepository : IRepository<EventId, EventResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Batched point-lookup: loads many results in a single query, keyed by
|
||||
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
|
||||
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
|
||||
IReadOnlyCollection<EventId> ids,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ public interface ISnapshotRepository
|
||||
{
|
||||
Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Server-side count of snapshots captured at or after <paramref name="since"/>.
|
||||
/// Backs the dashboard "snapshots today" stat without materialising rows.
|
||||
/// </summary>
|
||||
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||
EventId eventId,
|
||||
DateTimeOffset from,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Marathon.Application.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Database-pushdown query for the event list pages: an inclusive date range plus
|
||||
/// an optional sport-code filter. Locale-sensitive search and sort are deliberately
|
||||
/// NOT part of this contract — they stay in the service layer where Cyrillic
|
||||
/// ordinal semantics are preserved (SQLite BINARY collation would change them).
|
||||
/// </summary>
|
||||
/// <param name="Dates">Inclusive scheduled-at window.</param>
|
||||
/// <param name="SportCodes">When non-empty, restricts to these sport codes. Null/empty = all sports.</param>
|
||||
public sealed record EventQuery(
|
||||
DateRange Dates,
|
||||
IReadOnlyCollection<int>? SportCodes = null);
|
||||
@@ -54,16 +54,17 @@ public sealed class BuildBetJournalReportUseCase
|
||||
|
||||
var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList();
|
||||
|
||||
// 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<DomainEventId, OddsSnapshot?>(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;
|
||||
|
||||
@@ -71,9 +71,12 @@ public sealed class DetectAnomaliesUseCase
|
||||
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<OddsSnapshot>();
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, ct);
|
||||
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
|
||||
? slice
|
||||
: new List<Anomaly>();
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingForEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -117,7 +123,7 @@ public sealed class DetectAnomaliesUseCase
|
||||
AnomalyDetector detector,
|
||||
Event ev,
|
||||
IReadOnlyList<OddsSnapshot> snapshots,
|
||||
IReadOnlyList<Anomaly> existingAnomalies,
|
||||
List<Anomaly> 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)
|
||||
{
|
||||
|
||||
@@ -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<DomainEventId, Event>(distinctEventIds.Count);
|
||||
var resultLookup = new Dictionary<DomainEventId, EventResult>(distinctEventIds.Count);
|
||||
var eventTitles = new Dictionary<DomainEventId, string>(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<DomainEventId, string>(eventLookup.Count);
|
||||
foreach (var (id, ev) in eventLookup)
|
||||
eventTitles[id] = ev.Title;
|
||||
|
||||
// Evaluate every anomaly through the pure domain function.
|
||||
var resolved = new List<ResolvedAnomaly>();
|
||||
|
||||
@@ -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<Event>(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;
|
||||
|
||||
@@ -63,29 +63,16 @@ public sealed class RunBacktestUseCase
|
||||
return BacktestSimulator.Run(strategy, Array.Empty<BacktestCandidate>());
|
||||
}
|
||||
|
||||
// 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<DomainEventId, Event>(distinctEventIds.Count);
|
||||
var resultLookup = new Dictionary<DomainEventId, EventResult>(distinctEventIds.Count);
|
||||
var titles = new Dictionary<DomainEventId, string>(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<DomainEventId, string>(eventLookup.Count);
|
||||
foreach (var (id, ev) in eventLookup)
|
||||
titles[id] = ev.Title;
|
||||
|
||||
var candidates = new List<BacktestCandidate>(anomalies.Count);
|
||||
foreach (var anomaly in anomalies)
|
||||
|
||||
@@ -63,4 +63,11 @@ public sealed record Event(
|
||||
/// numeric event ID.
|
||||
/// </remarks>
|
||||
public string? EventPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display title in the canonical "{Side1Name} vs {Side2Name}" form. Single
|
||||
/// source for the home-vs-away join that was previously duplicated across the
|
||||
/// report use cases and list/feed services.
|
||||
/// </summary>
|
||||
public string Title => $"{Side1Name} vs {Side2Name}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marathon.Infrastructure.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(MarathonDbContext))]
|
||||
[Migration("20260528000000_AddSnapshotCapturedAtIndexes")]
|
||||
public partial class AddSnapshotCapturedAtIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Composite index for the dominant read shape: filter by EventCode + a
|
||||
// CapturedAt range, frequently with ORDER BY CapturedAt. Lets SQLite serve
|
||||
// both the predicate and the ordering from the index rather than scanning.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Snapshots_EventCode_CapturedAt",
|
||||
table: "Snapshots",
|
||||
columns: new[] { "EventCode", "CapturedAt" });
|
||||
|
||||
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Snapshots_EventCode_Source_CapturedAt",
|
||||
table: "Snapshots",
|
||||
columns: new[] { "EventCode", "Source", "CapturedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Snapshots_EventCode_Source_CapturedAt",
|
||||
table: "Snapshots");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Snapshots_EventCode_CapturedAt",
|
||||
table: "Snapshots");
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
b.Property<int>("Source").HasColumnType("INTEGER");
|
||||
b.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");
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,17 @@ internal sealed class SnapshotConfiguration : IEntityTypeConfiguration<SnapshotE
|
||||
|
||||
builder.HasIndex(s => 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)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
@@ -10,14 +9,13 @@ namespace Marathon.Infrastructure.Persistence;
|
||||
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
|
||||
/// Domain invariants are enforced on the domain side; mapping is purely structural.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and
|
||||
/// decoded exclusively through <see cref="SqliteDateText"/> so the write format and
|
||||
/// the repositories' range-predicate format can never drift apart.
|
||||
/// </remarks>
|
||||
internal static class Mapping
|
||||
{
|
||||
// ScheduledAt / CapturedAt / DetectedAt / CompletedAt are written via
|
||||
// DateTimeOffset.ToString("O") — round-trip ISO 8601. Parse with the
|
||||
// invariant culture and RoundtripKind so a non-en-US thread culture
|
||||
// (or a future locale change) cannot corrupt the round-trip.
|
||||
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
|
||||
|
||||
// ─── Bet scope discriminator constants ────────────────────────────────────
|
||||
private const int ScopeMatch = 0;
|
||||
private const int ScopePeriod = 1;
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,44 @@ internal sealed class AnomalyRepository : IAnomalyRepository
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
|
||||
{
|
||||
// Server-side COUNT(*) — the unread-badge hot path must not materialise the
|
||||
// whole table (with EvidenceJson) just to count. DetectedAt is stored as the
|
||||
// O-format TEXT key (see SqliteDateText); ">" matches the prior in-memory
|
||||
// GetUnreadCountAsync semantics (strictly newer than the last-seen marker).
|
||||
var sinceStr = SqliteDateText.Key(since);
|
||||
return await _db.Anomalies.AsNoTracking()
|
||||
.Where(a => a.DetectedAt.CompareTo(sinceStr) > 0)
|
||||
.CountAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
|
||||
DateTimeOffset? from,
|
||||
DateTimeOffset? to,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Anomalies.AsNoTracking();
|
||||
|
||||
if (from is { } f)
|
||||
{
|
||||
var fromStr = SqliteDateText.Key(f);
|
||||
q = q.Where(a => a.DetectedAt.CompareTo(fromStr) >= 0);
|
||||
}
|
||||
|
||||
if (to is { } t)
|
||||
{
|
||||
var toStr = SqliteDateText.Key(t);
|
||||
q = q.Where(a => a.DetectedAt.CompareTo(toStr) <= 0);
|
||||
}
|
||||
|
||||
var entities = await q
|
||||
.OrderByDescending(a => a.DetectedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
|
||||
{
|
||||
var efEntity = Mapping.ToEntity(entity);
|
||||
|
||||
@@ -26,9 +26,10 @@ internal sealed class EventRepository : IEventRepository
|
||||
|
||||
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
|
||||
{
|
||||
// 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<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var fromStr = SqliteDateText.Key(query.Dates.From);
|
||||
var toStr = SqliteDateText.Key(query.Dates.To);
|
||||
|
||||
// Date range + sport filter pushed to SQL so a multi-sport page no longer
|
||||
// materialises every event in the window. The composite
|
||||
// IX_Events_SportCode_ScheduledAt index covers this predicate. Case-sensitive
|
||||
// search / country filtering and locale-aware sorting stay in the service
|
||||
// layer where Cyrillic ordinal semantics are preserved.
|
||||
var q = _db.Events.AsNoTracking()
|
||||
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
|
||||
&& e.ScheduledAt.CompareTo(toStr) <= 0);
|
||||
|
||||
if (query.SportCodes is { Count: > 0 } sports)
|
||||
{
|
||||
var sportArray = sports.Distinct().ToArray();
|
||||
q = q.Where(e => sportArray.Contains(e.SportCode));
|
||||
}
|
||||
|
||||
var entities = await q.ToListAsync(ct);
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
|
||||
IReadOnlyCollection<EventId> ids,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ids);
|
||||
|
||||
var result = new Dictionary<EventId, Event>(ids.Count);
|
||||
if (ids.Count == 0)
|
||||
return result;
|
||||
|
||||
var codes = ids.Select(e => e.Value).Distinct().ToArray();
|
||||
|
||||
var entities = await _db.Events.AsNoTracking()
|
||||
.Where(e => codes.Contains(e.EventCode))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var domain = Mapping.ToDomain(entity);
|
||||
result[domain.Id] = domain;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
|
||||
{
|
||||
var entities = await _db.Events.AsNoTracking()
|
||||
@@ -50,6 +102,9 @@ internal sealed class EventRepository : IEventRepository
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct = default) =>
|
||||
_db.Events.AsNoTracking().CountAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var codes = await _db.Events.AsNoTracking()
|
||||
|
||||
@@ -23,6 +23,31 @@ internal sealed class ResultRepository : IResultRepository
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
|
||||
IReadOnlyCollection<EventId> ids,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ids);
|
||||
|
||||
var result = new Dictionary<EventId, EventResult>(ids.Count);
|
||||
if (ids.Count == 0)
|
||||
return result;
|
||||
|
||||
var codes = ids.Select(e => e.Value).Distinct().ToArray();
|
||||
|
||||
var entities = await _db.EventResults.AsNoTracking()
|
||||
.Where(r => codes.Contains(r.EventCode))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var domain = Mapping.ToDomain(entity);
|
||||
result[domain.EventId] = domain;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task AddAsync(EventResult entity, CancellationToken ct = default)
|
||||
{
|
||||
var efEntity = Mapping.ToEntity(entity);
|
||||
|
||||
@@ -19,14 +19,22 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
|
||||
{
|
||||
var sinceStr = SqliteDateText.Key(since);
|
||||
return _db.Snapshots.AsNoTracking()
|
||||
.Where(s => s.CapturedAt.CompareTo(sinceStr) >= 0)
|
||||
.CountAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Marathon.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for how <see cref="DateTimeOffset"/> values are encoded
|
||||
/// as the TEXT used by both the write path (<see cref="Mapping"/>) and the
|
||||
/// date-range predicates / ORDER BY clauses in the repositories.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Dates are stored as round-trip ISO-8601 (<c>"O"</c> format) TEXT. SQLite TEXT
|
||||
/// columns use BINARY (ordinal) collation by default, so the relational operators
|
||||
/// (<c>>=</c>, <c><=</c>) and <c>ORDER BY</c> on these strings sort
|
||||
/// <b>chronologically</b> — but ONLY because every persisted timestamp carries the
|
||||
/// same Moscow <c>+03:00</c> offset (see the project invariant in CLAUDE.md). Two
|
||||
/// instants written with different offsets would sort lexically, not
|
||||
/// chronologically, and silently corrupt range filtering.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Centralising the format here means the write encoding and the query-bound
|
||||
/// encoding can never drift apart, and the offset invariant is documented in one
|
||||
/// authoritative place. If a future change normalises storage to UTC or a native
|
||||
/// DATETIME column, this is the only call site that must change.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SqliteDateText
|
||||
{
|
||||
// Parse with the invariant culture + RoundtripKind so a non-en-US thread
|
||||
// culture (or a future locale change) cannot corrupt the round-trip.
|
||||
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a <see cref="DateTimeOffset"/> as the TEXT key used for storage and
|
||||
/// for the bounds of range/ordering predicates.
|
||||
/// </summary>
|
||||
public static string Key(DateTimeOffset value) => value.ToString("O");
|
||||
|
||||
/// <summary>Decodes a stored TEXT key back into a <see cref="DateTimeOffset"/>.</summary>
|
||||
public static DateTimeOffset Parse(string text) =>
|
||||
DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, RoundtripStyles);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<AnomalyListItem>();
|
||||
|
||||
// Resolve event metadata in one pass — distinct EventIds only.
|
||||
// Resolve event metadata in one batched pass — distinct EventIds only.
|
||||
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
|
||||
|
||||
var items = new List<AnomalyListItem>(all.Count);
|
||||
@@ -44,7 +46,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters in-memory (small list, UI page).
|
||||
// Remaining filters in-memory (page-sized set).
|
||||
IEnumerable<AnomalyListItem> filtered = items;
|
||||
|
||||
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<int> 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<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
|
||||
// Server-side COUNT(*) — no longer materialises the table to count.
|
||||
=> _anomalies.CountSinceAsync(since, ct);
|
||||
|
||||
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -125,14 +110,8 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var dict = new Dictionary<DomainEventId, Event>(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);
|
||||
|
||||
@@ -40,15 +40,15 @@ public sealed class BetJournalService : IBetJournalService
|
||||
if (report.Bets.Count == 0)
|
||||
return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>());
|
||||
|
||||
// Resolve event titles in one pass — distinct ids only.
|
||||
// Resolve event titles in one batched query — distinct ids only (was N+1).
|
||||
// Missing events (pruned by snapshot retention) fall back to the raw id.
|
||||
var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList();
|
||||
var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
|
||||
var titles = new Dictionary<DomainEventId, string>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Event> filtered = events;
|
||||
|
||||
if (filter.SportCodes is { Count: > 0 } sports)
|
||||
filtered = filtered.Where(e => sports.Contains(e.Sport.Value));
|
||||
|
||||
if (filter.CountryCodes is { Count: > 0 } countries)
|
||||
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ public sealed class BuildBetJournalReportUseCaseTests
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
private readonly ISnapshotRepository _snapshots = Substitute.For<ISnapshotRepository>();
|
||||
|
||||
public BuildBetJournalReportUseCaseTests()
|
||||
{
|
||||
// Use case batches event loads via GetManyAsync; route through per-id stubs.
|
||||
TestFixtures.BridgeGetMany(_events);
|
||||
}
|
||||
|
||||
private BuildBetJournalReportUseCase CreateSut() =>
|
||||
new(_bets, _events, _snapshots, NullLogger<BuildBetJournalReportUseCase>.Instance);
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ public sealed class EvaluateAnomalyOutcomesUseCaseTests
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||
|
||||
public EvaluateAnomalyOutcomesUseCaseTests()
|
||||
{
|
||||
// Use cases batch event/result loads via GetManyAsync; route those through
|
||||
// the per-id GetAsync stubs each test already configures.
|
||||
TestFixtures.BridgeGetMany(_events);
|
||||
TestFixtures.BridgeGetMany(_results);
|
||||
}
|
||||
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly 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<CancellationToken>())
|
||||
.Returns(new[] { MakeAnomaly(id1, 0.55m), MakeAnomaly(id2, 0.55m) }.ToList().AsReadOnly());
|
||||
|
||||
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, Event> { [id1] = MakeEvent(id1, 11), [id2] = MakeEvent(id2, 6) });
|
||||
_results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, EventResult>());
|
||||
|
||||
await CreateSut().ExecuteAsync(CancellationToken.None);
|
||||
|
||||
await _events.Received(1)
|
||||
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
|
||||
await _events.DidNotReceive()
|
||||
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
|
||||
await _results.Received(1)
|
||||
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
|
||||
await _results.DidNotReceive()
|
||||
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets()
|
||||
{
|
||||
|
||||
@@ -17,6 +17,13 @@ public sealed class PullResultsUseCaseTests
|
||||
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
|
||||
|
||||
public PullResultsUseCaseTests()
|
||||
{
|
||||
// Selection-mode candidate resolution now batches via GetManyAsync; route
|
||||
// it through the per-id GetAsync stubs each test configures.
|
||||
TestFixtures.BridgeGetMany(_eventRepo);
|
||||
}
|
||||
|
||||
private static readonly DateRange AnyRange = new(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
@@ -25,6 +25,13 @@ public sealed class RunBacktestUseCaseTests
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||
|
||||
public RunBacktestUseCaseTests()
|
||||
{
|
||||
// Use case batches event/result loads via GetManyAsync; route through per-id stubs.
|
||||
TestFixtures.BridgeGetMany(_events);
|
||||
TestFixtures.BridgeGetMany(_results);
|
||||
}
|
||||
|
||||
private RunBacktestUseCase CreateSut() =>
|
||||
new(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.Instance);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Bridges the legacy per-id <c>GetAsync</c> stubs to the batched
|
||||
/// <c>GetManyAsync</c> the use cases now call: each requested id is resolved
|
||||
/// through whatever <c>GetAsync</c> was configured to return for it. Lets the
|
||||
/// existing per-id <c>.Returns(...)</c> setups keep working unchanged.
|
||||
/// </summary>
|
||||
public static void BridgeGetMany(IEventRepository events)
|
||||
{
|
||||
events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
|
||||
var dict = new Dictionary<EventId, Event>();
|
||||
foreach (var id in ids.Distinct())
|
||||
{
|
||||
var ev = events.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (ev is not null) dict[id] = ev;
|
||||
}
|
||||
return (IReadOnlyDictionary<EventId, Event>)dict;
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="BridgeGetMany(IEventRepository)"/>
|
||||
public static void BridgeGetMany(IResultRepository results)
|
||||
{
|
||||
results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
|
||||
var dict = new Dictionary<EventId, EventResult>();
|
||||
foreach (var id in ids.Distinct())
|
||||
{
|
||||
var r = results.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (r is not null) dict[id] = r;
|
||||
}
|
||||
return (IReadOnlyDictionary<EventId, EventResult>)dict;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Creates a minimal valid <see cref="Event"/> with the given event ID string.</summary>
|
||||
public static Event MakeEvent(string eventIdValue = "12345678")
|
||||
{
|
||||
|
||||
@@ -32,6 +32,21 @@ public sealed class ResultsLoaderTests : MarathonTestContext
|
||||
sp.GetRequiredService<IEventRepository>(),
|
||||
sp.GetRequiredService<IResultRepository>(),
|
||||
NullLogger<PullResultsUseCase>.Instance));
|
||||
|
||||
// PullResultsUseCase batches selection-mode candidate resolution via
|
||||
// GetManyAsync; route it through whatever GetAsync the test configures.
|
||||
_eventRepo.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
|
||||
var dict = new Dictionary<EventId, Event>();
|
||||
foreach (var id in ids.Distinct())
|
||||
{
|
||||
var ev = _eventRepo.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (ev is not null) dict[id] = ev;
|
||||
}
|
||||
return (IReadOnlyDictionary<EventId, Event>)dict;
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user