perf: batch repository reads, index snapshots, centralize date encoding

- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
  6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
  results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
  load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
  (feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
  _EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
  shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
  DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.

Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
This commit is contained in:
2026-05-28 22:34:08 +03:00
parent 0d52b7beff
commit f294255f10
30 changed files with 522 additions and 145 deletions
@@ -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")
{