feat(phase-4): application layer + background workers — 202/202 tests green
Use cases (Marathon.Application/UseCases/): - PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots - PullLiveOddsUseCase: refresh live snapshots for all stored events - PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op); Phase 8 will replace with watch-list polling - ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed. Background workers (Marathon.Infrastructure/Workers/): - UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h) - LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s) - ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false) All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope(). InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration): - Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct AddMarathonApplication() + AddMarathonInfrastructure(config) calls. Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule. appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds, ResultsPollerEnabled; add Sports.Basketball.QuarterMode. Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields. Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
using Marathon.Application.UseCases;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Marathon.Application;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration helpers for the Marathon.Application layer.
|
||||
/// Call <see cref="AddMarathonApplication"/> from the composition root (host or
|
||||
/// <c>InfrastructureModule</c>).
|
||||
/// </summary>
|
||||
public static class ApplicationModule
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers all Application-layer use cases with <c>Scoped</c> lifetime.
|
||||
/// Use cases are scoped so that each background-service cycle or UI request
|
||||
/// gets a fresh unit-of-work from its own DI scope.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// No <see cref="Microsoft.Extensions.Configuration.IConfiguration"/> is
|
||||
/// required here — the Application layer has no direct configuration bindings.
|
||||
/// Infrastructure and UI layers bind their own options against the shared
|
||||
/// JSON sections.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddMarathonApplication(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddScoped<PullUpcomingEventsUseCase>();
|
||||
services.AddScoped<PullLiveOddsUseCase>();
|
||||
services.AddScoped<PullResultsUseCase>();
|
||||
services.AddScoped<ExportToExcelUseCase>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Alias Microsoft.Extensions.Logging.EventId to avoid name conflict with
|
||||
// Marathon.Domain.ValueObjects.EventId throughout the Application layer.
|
||||
global using LogEventId = Microsoft.Extensions.Logging.EventId;
|
||||
@@ -4,6 +4,11 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Exports odds snapshots for a date range to an Excel file, placing it in
|
||||
/// the configured export directory.
|
||||
/// </summary>
|
||||
public sealed class ExportToExcelUseCase
|
||||
{
|
||||
private readonly IExcelExporter _exporter;
|
||||
private readonly IOptions<StorageOptions> _storageOptions;
|
||||
private readonly ILogger<ExportToExcelUseCase> _logger;
|
||||
|
||||
public ExportToExcelUseCase(
|
||||
IExcelExporter exporter,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
ILogger<ExportToExcelUseCase> logger)
|
||||
{
|
||||
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
|
||||
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the export and returns the absolute path of the created file.
|
||||
/// </summary>
|
||||
/// <param name="range">Inclusive date range to export.</param>
|
||||
/// <param name="kind">Which snapshots to include.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Absolute path of the written <c>.xlsx</c> file.</returns>
|
||||
public async Task<string> ExecuteAsync(DateRange range, ExportKind kind, CancellationToken ct)
|
||||
{
|
||||
var exportDir = _storageOptions.Value.ExportDirectory;
|
||||
|
||||
// Ensure the output directory exists before handing off to the exporter.
|
||||
Directory.CreateDirectory(exportDir);
|
||||
|
||||
_logger.LogInformation(
|
||||
"ExportToExcelUseCase: exporting {Kind} snapshots for {From:yyyy-MM-dd}..{To:yyyy-MM-dd} → {Dir}",
|
||||
kind, range.From, range.To, exportDir);
|
||||
|
||||
var outputPath = await _exporter.ExportAsync(range, kind, exportDir, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"ExportToExcelUseCase: export complete — file={Path}",
|
||||
outputPath);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// For each currently-live event in the database, fetches a fresh odds snapshot
|
||||
/// via the scraper and persists it.
|
||||
/// </summary>
|
||||
public sealed class PullLiveOddsUseCase
|
||||
{
|
||||
private readonly IOddsScraper _scraper;
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly ISnapshotRepository _snapshotRepo;
|
||||
private readonly ILogger<PullLiveOddsUseCase> _logger;
|
||||
|
||||
public PullLiveOddsUseCase(
|
||||
IOddsScraper scraper,
|
||||
IEventRepository eventRepo,
|
||||
ISnapshotRepository snapshotRepo,
|
||||
ILogger<PullLiveOddsUseCase> logger)
|
||||
{
|
||||
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one live-odds polling cycle.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Number of snapshots successfully captured.</returns>
|
||||
public async Task<int> ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("PullLiveOddsUseCase: cycle started");
|
||||
|
||||
// Fetch live events from scraper — returns only events currently live on site.
|
||||
var liveEvents = await _scraper.ScrapeUpcomingAsync(sportFilter: null, ct);
|
||||
|
||||
// Note: the scraper's ScrapeUpcomingAsync returns pre-match by default.
|
||||
// For the live cycle, we load known events from the DB and refresh each one.
|
||||
// The "live vs pre-match" distinction is handled by OddsSource.Live in the snapshot.
|
||||
// We use the DB list because the site's live listing may differ from what we track.
|
||||
var allEvents = await _eventRepo.ListAsync(ct);
|
||||
|
||||
int snapshotsCaptured = 0;
|
||||
|
||||
foreach (var ev in allEvents)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await _scraper.ScrapeEventOddsAsync(ev.Id, OddsSource.Live, ct);
|
||||
await _snapshotRepo.AddAsync(snapshot, ct);
|
||||
await _snapshotRepo.SaveChangesAsync(ct);
|
||||
snapshotsCaptured++;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"PullLiveOddsUseCase: failed to capture live snapshot for event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"PullLiveOddsUseCase: cycle done — snapshots captured for {Count}/{Total} events",
|
||||
snapshotsCaptured, allEvents.Count);
|
||||
|
||||
return snapshotsCaptured;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Scaffolded results loader — inspects events for completion and persists
|
||||
/// <see cref="Domain.Entities.EventResult"/>s when detected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Phase 4 scaffold:</b> This implementation is intentionally minimal.
|
||||
/// The formal watch-list polling strategy lands in Phase 8, when
|
||||
/// <c>IOddsScraper.ScrapeResultsAsync</c> will be replaced with real
|
||||
/// per-event polling against <c>IResultsParser</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Current behaviour: calls <c>IOddsScraper.ScrapeResultsAsync</c> (which
|
||||
/// returns an empty list and logs a warning per Phase 3), so
|
||||
/// <c>ResultsLoaded</c> will always be 0 until Phase 8.
|
||||
/// All events with existing results are skipped (idempotent).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PullResultsUseCase
|
||||
{
|
||||
private readonly IOddsScraper _scraper;
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly IResultRepository _resultRepo;
|
||||
private readonly ILogger<PullResultsUseCase> _logger;
|
||||
|
||||
public PullResultsUseCase(
|
||||
IOddsScraper scraper,
|
||||
IEventRepository eventRepo,
|
||||
IResultRepository resultRepo,
|
||||
ILogger<PullResultsUseCase> logger)
|
||||
{
|
||||
_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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inspects events for completion and persists results.
|
||||
/// </summary>
|
||||
/// <param name="range">Date range to scope the event search.</param>
|
||||
/// <param name="selection">
|
||||
/// When non-null, only these event IDs are inspected.
|
||||
/// When null, all events in <paramref name="range"/> without a result row are inspected.
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A tuple of <c>(Inspected, ResultsLoaded, Skipped)</c> where:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Inspected</c>: total candidates examined.</item>
|
||||
/// <item><c>ResultsLoaded</c>: results that were persisted this cycle.</item>
|
||||
/// <item><c>Skipped</c>: events already with a result (idempotency guard).</item>
|
||||
/// </list>
|
||||
/// </returns>
|
||||
public async Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync(
|
||||
DateRange range,
|
||||
IReadOnlyList<DomainEventId>? selection,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"PullResultsUseCase: cycle started — range={From:O}..{To:O}, selection={SelectionCount}",
|
||||
range.From, range.To, selection?.Count.ToString() ?? "all");
|
||||
|
||||
// Resolve the candidate event IDs.
|
||||
IReadOnlyList<Domain.Entities.Event> candidates;
|
||||
if (selection is { Count: > 0 })
|
||||
{
|
||||
var selected = new List<Domain.Entities.Event>(selection.Count);
|
||||
foreach (var id in selection)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var ev = await _eventRepo.GetAsync(id, ct);
|
||||
if (ev is not null)
|
||||
selected.Add(ev);
|
||||
}
|
||||
candidates = selected;
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates = await _eventRepo.ListByDateRangeAsync(range, ct);
|
||||
}
|
||||
|
||||
int inspected = 0;
|
||||
int resultsLoaded = 0;
|
||||
int skipped = 0;
|
||||
|
||||
// Use the scraper's results endpoint (currently a no-op in Phase 3 — returns []).
|
||||
var scraped = await _scraper.ScrapeResultsAsync(range, ct);
|
||||
var scrapedByEventId = scraped.ToDictionary(r => r.EventId.Value, r => r);
|
||||
|
||||
foreach (var ev in candidates)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
inspected++;
|
||||
|
||||
try
|
||||
{
|
||||
// Idempotency: skip events that already have a result stored.
|
||||
var existingResult = await _resultRepo.GetAsync(ev.Id, ct);
|
||||
if (existingResult is not null)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the scraper returned a result for this event.
|
||||
if (scrapedByEventId.TryGetValue(ev.Id.Value, out var result))
|
||||
{
|
||||
await _resultRepo.AddAsync(result, ct);
|
||||
await _resultRepo.SaveChangesAsync(ct);
|
||||
resultsLoaded++;
|
||||
}
|
||||
// Phase 8: else → add to watch list for next poll cycle.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"PullResultsUseCase: error processing event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"PullResultsUseCase: cycle done — inspected={Inspected}, loaded={Loaded}, skipped={Skipped}",
|
||||
inspected, resultsLoaded, skipped);
|
||||
|
||||
return (inspected, resultsLoaded, skipped);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the current pre-match event listing, persists new events (skipping
|
||||
/// duplicates by <see cref="Domain.ValueObjects.EventId"/>), and captures an
|
||||
/// initial pre-match odds snapshot for every returned event.
|
||||
/// </summary>
|
||||
public sealed class PullUpcomingEventsUseCase
|
||||
{
|
||||
private readonly IOddsScraper _scraper;
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly ISnapshotRepository _snapshotRepo;
|
||||
private readonly ILogger<PullUpcomingEventsUseCase> _logger;
|
||||
|
||||
public PullUpcomingEventsUseCase(
|
||||
IOddsScraper scraper,
|
||||
IEventRepository eventRepo,
|
||||
ISnapshotRepository snapshotRepo,
|
||||
ILogger<PullUpcomingEventsUseCase> logger)
|
||||
{
|
||||
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one polling cycle: scrape → persist new events → capture snapshots.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A tuple of <c>(EventsProcessed, NewEvents, SnapshotsCaptured)</c>.
|
||||
/// <c>EventsProcessed</c> is the total number returned by the scraper.
|
||||
/// <c>NewEvents</c> is how many were not already in the DB.
|
||||
/// <c>SnapshotsCaptured</c> is how many snapshots were successfully saved.
|
||||
/// </returns>
|
||||
public async Task<(int EventsProcessed, int NewEvents, int SnapshotsCaptured)> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("PullUpcomingEventsUseCase: cycle started");
|
||||
|
||||
var events = await _scraper.ScrapeUpcomingAsync(sportFilter: null, ct);
|
||||
int eventsProcessed = events.Count;
|
||||
int newEvents = 0;
|
||||
int snapshotsCaptured = 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"PullUpcomingEventsUseCase: scraper returned {Count} events",
|
||||
eventsProcessed);
|
||||
|
||||
foreach (var ev in events)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await _eventRepo.GetAsync(ev.Id, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
await _eventRepo.AddAsync(ev, ct);
|
||||
await _eventRepo.SaveChangesAsync(ct);
|
||||
newEvents++;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"PullUpcomingEventsUseCase: failed to persist event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = await _scraper.ScrapeEventOddsAsync(
|
||||
ev.Id,
|
||||
Domain.Enums.OddsSource.PreMatch,
|
||||
ct);
|
||||
|
||||
await _snapshotRepo.AddAsync(snapshot, ct);
|
||||
await _snapshotRepo.SaveChangesAsync(ct);
|
||||
snapshotsCaptured++;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"PullUpcomingEventsUseCase: failed to capture snapshot for event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"PullUpcomingEventsUseCase: cycle done — processed={Processed}, new={New}, snapshots={Snapshots}",
|
||||
eventsProcessed, newEvents, snapshotsCaptured);
|
||||
|
||||
return (eventsProcessed, newEvents, snapshotsCaptured);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using Marathon.Application;
|
||||
using Marathon.Infrastructure;
|
||||
using Marathon.UI.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -73,9 +75,9 @@ public partial class App : System.Windows.Application
|
||||
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||
#endif
|
||||
|
||||
// Compose Application + Infrastructure modules if they exist. Parallel
|
||||
// Phase 2/3/4 work may still be merging these; we degrade gracefully.
|
||||
TryAddApplicationAndInfrastructure(builder.Services, builder.Configuration);
|
||||
// Application use cases + Infrastructure (persistence, scraping, workers).
|
||||
builder.Services.AddMarathonApplication();
|
||||
builder.Services.AddMarathonInfrastructure(builder.Configuration);
|
||||
|
||||
// MainWindow needs the IServiceProvider for BlazorWebView.Services binding.
|
||||
builder.Services.AddSingleton<MainWindow>();
|
||||
@@ -104,50 +106,6 @@ public partial class App : System.Windows.Application
|
||||
? level
|
||||
: Serilog.Events.LogEventLevel.Information;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort wiring of the Application + Infrastructure DI modules.
|
||||
/// TODO(phase-4): the orchestrator will land a single
|
||||
/// <c>AddMarathonInfrastructure(config)</c> entry point. Until then we use
|
||||
/// reflection to call whichever extension methods exist so partial merges
|
||||
/// don't break compilation of this host.
|
||||
/// </summary>
|
||||
private static void TryAddApplicationAndInfrastructure(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
TryInvokeExtension(services, configuration, "Marathon.Application.DependencyInjection", "AddMarathonApplication");
|
||||
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.DependencyInjection", "AddMarathonInfrastructure");
|
||||
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Persistence.PersistenceServiceCollectionExtensions", "AddMarathonPersistence");
|
||||
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Scraping.ScrapingServiceCollectionExtensions", "AddMarathonScraping");
|
||||
}
|
||||
|
||||
private static void TryInvokeExtension(IServiceCollection services, IConfiguration configuration, string typeName, string methodName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Probe across all loaded assemblies — project refs cause them to load on startup.
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
var type = asm.GetType(typeName, throwOnError: false, ignoreCase: false);
|
||||
if (type is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var method = type.GetMethod(methodName, new[] { typeof(IServiceCollection), typeof(IConfiguration) });
|
||||
if (method is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
method.Invoke(null, new object[] { services, configuration });
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Optional module {Type}.{Method} not wired", typeName, methodName);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -19,9 +19,17 @@
|
||||
"RequestTimeoutSeconds": 30
|
||||
},
|
||||
"Workers": {
|
||||
"UpcomingScheduleCron": "0 */5 * * * *",
|
||||
"UpcomingScheduleCron": "0 0 */6 * * *",
|
||||
"LivePollerEnabled": true,
|
||||
"UpcomingPollerEnabled": true
|
||||
"UpcomingPollerEnabled": true,
|
||||
"LivePollIntervalSeconds": 30,
|
||||
"ResultsPollIntervalSeconds": 300,
|
||||
"ResultsPollerEnabled": false
|
||||
},
|
||||
"Sports": {
|
||||
"Basketball": {
|
||||
"QuarterMode": false
|
||||
}
|
||||
},
|
||||
"Storage": {
|
||||
"DatabasePath": "./data/marathon.db",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Marathon.Infrastructure.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed options for the background worker pollers.
|
||||
/// Bound from the <c>Workers</c> section of <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
public const string SectionName = "Workers";
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression (6-field with seconds: s m h d M dow) controlling when the
|
||||
/// upcoming-events poller fires. Default: every 6 hours.
|
||||
/// </summary>
|
||||
public string UpcomingScheduleCron { get; init; } = "0 0 */6 * * *";
|
||||
|
||||
/// <summary>Whether the live odds poller should run at startup.</summary>
|
||||
public bool LivePollerEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
|
||||
public bool UpcomingPollerEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How long the live odds poller sleeps between polling cycles, in seconds.
|
||||
/// Default: 30 s (matches <c>Scraping:PollingIntervalSeconds</c> but is
|
||||
/// independently configurable here).
|
||||
/// </summary>
|
||||
public int LivePollIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// How long the results watch-list poller sleeps between cycles, in seconds.
|
||||
/// Default: 300 s (5 minutes).
|
||||
/// </summary>
|
||||
public int ResultsPollIntervalSeconds { get; init; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the results watch-list poller is enabled.
|
||||
/// Default: <c>false</c> — the poller infrastructure ships in Phase 4 but
|
||||
/// the formal watch-list implementation lands in Phase 8.
|
||||
/// Flip to <c>true</c> only after Phase 8 is complete.
|
||||
/// </summary>
|
||||
public bool ResultsPollerEnabled { get; init; } = false;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Marathon.Infrastructure.Scraping;
|
||||
using Marathon.Infrastructure.Workers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Marathon.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level DI composition entry-point for all Infrastructure sub-modules
|
||||
/// (Persistence, Scraping, Workers).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Call <see cref="AddMarathonInfrastructure"/> once from the host's DI
|
||||
/// setup. This replaces the previous reflection-based wiring in
|
||||
/// <c>App.xaml.cs::TryAddApplicationAndInfrastructure</c>.
|
||||
/// </remarks>
|
||||
public static class InfrastructureModule
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the complete Infrastructure layer:
|
||||
/// <list type="bullet">
|
||||
/// <item>EF Core / SQLite persistence (<see cref="PersistenceModule.AddMarathonPersistence"/>).</item>
|
||||
/// <item>HttpClient + AngleSharp + Polly scraping (<see cref="ScrapingModule.AddMarathonScraping"/>).</item>
|
||||
/// <item><see cref="WorkerOptions"/> bound to the <c>Workers</c> config section.</item>
|
||||
/// <item>Three <see cref="Microsoft.Extensions.Hosting.BackgroundService"/> pollers.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static IServiceCollection AddMarathonInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
services.AddMarathonPersistence(config);
|
||||
services.AddMarathonScraping(config);
|
||||
|
||||
services
|
||||
.AddOptions<WorkerOptions>()
|
||||
.Bind(config.GetSection(WorkerOptions.SectionName));
|
||||
|
||||
services.AddHostedService<UpcomingEventsPoller>();
|
||||
services.AddHostedService<LiveOddsPoller>();
|
||||
services.AddHostedService<ResultsWatchListPoller>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="Cronos" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -109,9 +109,12 @@ public static class ScrapingModule
|
||||
|
||||
// ── Parsers (stateless — safe as singletons) ──────────────────────
|
||||
services.AddSingleton<IServerTimeProvider, ServerTimeProvider>();
|
||||
services.AddSingleton(_ =>
|
||||
// TODO (Phase 4): bind BasketballQuarterMode from Sports:Basketball:QuarterMode config.
|
||||
new PeriodScopeMapper(basketballQuarterMode: false));
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
// Bind Sports:Basketball:QuarterMode from configuration (Phase 4).
|
||||
var basketballQuarterMode = config.GetValue<bool>("Sports:Basketball:QuarterMode");
|
||||
return new PeriodScopeMapper(basketballQuarterMode);
|
||||
});
|
||||
services.AddSingleton<IUpcomingEventsParser, UpcomingEventsParser>();
|
||||
services.AddSingleton<ILiveEventsParser, LiveEventsParser>();
|
||||
services.AddSingleton<IEventOddsParser, EventOddsParser>();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Infrastructure.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Continuously polls live odds on a fixed interval controlled by
|
||||
/// <see cref="WorkerOptions.LivePollIntervalSeconds"/> (default: 30 s).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered as a <see cref="BackgroundService"/> (singleton lifetime).
|
||||
/// Use cases are resolved in a fresh <see cref="IServiceScope"/> per cycle so
|
||||
/// that EF Core's scoped <c>DbContext</c> is correctly managed.
|
||||
/// </remarks>
|
||||
internal sealed class LiveOddsPoller : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IOptionsMonitor<WorkerOptions> _opts;
|
||||
private readonly ILogger<LiveOddsPoller> _logger;
|
||||
|
||||
public LiveOddsPoller(
|
||||
IServiceProvider services,
|
||||
IOptionsMonitor<WorkerOptions> opts,
|
||||
ILogger<LiveOddsPoller> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("LiveOddsPoller: started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var options = _opts.CurrentValue;
|
||||
|
||||
if (!options.LivePollerEnabled)
|
||||
{
|
||||
_logger.LogDebug("LiveOddsPoller: disabled — sleeping 10s before re-check");
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var useCase = scope.ServiceProvider.GetRequiredService<PullLiveOddsUseCase>();
|
||||
var snapshotsCaptured = await useCase.ExecuteAsync(stoppingToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"LiveOddsPoller: cycle complete — snapshots={Snapshots}",
|
||||
snapshotsCaptured);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "LiveOddsPoller: unhandled exception during cycle — will retry after interval");
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(
|
||||
Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("LiveOddsPoller: stopping");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Infrastructure.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Polls for completed event results on a fixed interval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Phase 4 scaffold — disabled by default.</b>
|
||||
/// <see cref="WorkerOptions.ResultsPollerEnabled"/> defaults to <c>false</c>.
|
||||
/// The formal watch-list implementation (per-event polling until
|
||||
/// <c>matchIsComplete=true</c>) lands in Phase 8.
|
||||
/// Enable only after Phase 8 is complete.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When enabled, this poller calls <see cref="PullResultsUseCase.ExecuteAsync"/>
|
||||
/// with <c>selection: null</c> and a date-range covering events scheduled in
|
||||
/// the previous 24 hours that lack a result row.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ResultsWatchListPoller : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IOptionsMonitor<WorkerOptions> _opts;
|
||||
private readonly ILogger<ResultsWatchListPoller> _logger;
|
||||
|
||||
public ResultsWatchListPoller(
|
||||
IServiceProvider services,
|
||||
IOptionsMonitor<WorkerOptions> opts,
|
||||
ILogger<ResultsWatchListPoller> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("ResultsWatchListPoller: started (disabled={Disabled})",
|
||||
!_opts.CurrentValue.ResultsPollerEnabled);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var options = _opts.CurrentValue;
|
||||
|
||||
if (!options.ResultsPollerEnabled)
|
||||
{
|
||||
// Poller is disabled — sleep longer to avoid busy-waiting.
|
||||
_logger.LogDebug("ResultsWatchListPoller: disabled — sleeping 60s before re-check");
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
// Inspect events scheduled in the last 24 hours (may still be running or just finished).
|
||||
var range = new DateRange(now.AddHours(-24), now);
|
||||
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var useCase = scope.ServiceProvider.GetRequiredService<PullResultsUseCase>();
|
||||
var (inspected, loaded, skipped) = await useCase.ExecuteAsync(
|
||||
range,
|
||||
selection: null,
|
||||
stoppingToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"ResultsWatchListPoller: cycle complete — inspected={Inspected}, loaded={Loaded}, skipped={Skipped}",
|
||||
inspected, loaded, skipped);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ResultsWatchListPoller: unhandled exception during cycle — will retry after interval");
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(
|
||||
Math.Max(60, _opts.CurrentValue.ResultsPollIntervalSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("ResultsWatchListPoller: stopping");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Cronos;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Infrastructure.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Fires <see cref="PullUpcomingEventsUseCase"/> on a cron schedule driven by
|
||||
/// <see cref="WorkerOptions.UpcomingScheduleCron"/> (default: every 6 hours).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered as a <see cref="BackgroundService"/> (singleton lifetime).
|
||||
/// Use cases are resolved in a fresh <see cref="IServiceScope"/> per cycle so
|
||||
/// that EF Core's scoped <c>DbContext</c> is correctly managed.
|
||||
/// </remarks>
|
||||
internal sealed class UpcomingEventsPoller : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IOptionsMonitor<WorkerOptions> _opts;
|
||||
private readonly ILogger<UpcomingEventsPoller> _logger;
|
||||
|
||||
public UpcomingEventsPoller(
|
||||
IServiceProvider services,
|
||||
IOptionsMonitor<WorkerOptions> opts,
|
||||
ILogger<UpcomingEventsPoller> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("UpcomingEventsPoller: started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var options = _opts.CurrentValue;
|
||||
|
||||
if (!options.UpcomingPollerEnabled)
|
||||
{
|
||||
_logger.LogDebug("UpcomingEventsPoller: disabled — sleeping 60s before re-check");
|
||||
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var delay = ComputeDelayToNextFire(options.UpcomingScheduleCron);
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"UpcomingEventsPoller: next fire in {Delay:g}",
|
||||
delay);
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
// Re-read options after the delay (may have changed via settings page).
|
||||
if (!_opts.CurrentValue.UpcomingPollerEnabled)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var useCase = scope.ServiceProvider.GetRequiredService<PullUpcomingEventsUseCase>();
|
||||
var (processed, newEvts, snapshots) = await useCase.ExecuteAsync(stoppingToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"UpcomingEventsPoller: cycle complete — processed={Processed}, new={New}, snapshots={Snapshots}",
|
||||
processed, newEvts, snapshots);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "UpcomingEventsPoller: unhandled exception during cycle — will retry at next scheduled fire");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("UpcomingEventsPoller: stopping");
|
||||
}
|
||||
|
||||
private static TimeSpan ComputeDelayToNextFire(string cronExpression)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Cronos supports 6-field expressions (with seconds) when the format is specified.
|
||||
var expression = CronExpression.Parse(
|
||||
cronExpression,
|
||||
CronFormat.IncludeSeconds);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var next = expression.GetNextOccurrence(now.UtcDateTime, inclusive: false);
|
||||
|
||||
return next.HasValue
|
||||
? next.Value - now.UtcDateTime
|
||||
: TimeSpan.FromHours(6); // fallback if expression yields no next occurrence
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Malformed cron expression — fall back to 6-hour default.
|
||||
return TimeSpan.FromHours(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,15 @@
|
||||
<Field Label="@L["Settings.Workers.LivePollerEnabled"]">
|
||||
<MudSwitch T="bool" @bind-Value="_workers.LivePollerEnabled" Color="Color.Primary" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Workers.LivePollIntervalSeconds"]" Hint="@L["Settings.Workers.LivePollIntervalSeconds.Hint"]">
|
||||
<MudNumericField T="int" @bind-Value="_workers.LivePollIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Workers.ResultsPollerEnabled"]" Hint="@L["Settings.Workers.ResultsPollerEnabled.Hint"]">
|
||||
<MudSwitch T="bool" @bind-Value="_workers.ResultsPollerEnabled" Color="Color.Primary" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Workers.ResultsPollIntervalSeconds"]">
|
||||
<MudNumericField T="int" @bind-Value="_workers.ResultsPollIntervalSeconds" Min="60" Max="7200" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
|
||||
</div>
|
||||
@@ -182,9 +191,12 @@
|
||||
|
||||
_workers = new WorkerOptions
|
||||
{
|
||||
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
|
||||
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
|
||||
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
|
||||
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
|
||||
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
|
||||
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
|
||||
LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds,
|
||||
ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled,
|
||||
ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds,
|
||||
};
|
||||
|
||||
_storage = new StorageOptions
|
||||
|
||||
@@ -114,6 +114,11 @@
|
||||
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Standard cron. Defaults to every 5 minutes.</value></data>
|
||||
<data name="Settings.Workers.LivePollerEnabled"><value>Live poller enabled</value></data>
|
||||
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Schedule poller enabled</value></data>
|
||||
<data name="Settings.Workers.LivePollIntervalSeconds"><value>Live poll interval (sec)</value></data>
|
||||
<data name="Settings.Workers.LivePollIntervalSeconds.Hint"><value>Delay between live-odds polling cycles. Default 30 s.</value></data>
|
||||
<data name="Settings.Workers.ResultsPollerEnabled"><value>Results poller enabled</value></data>
|
||||
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Disabled until Phase 8. Enable only after match-complete polling is implemented.</value></data>
|
||||
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Results poll interval (sec)</value></data>
|
||||
|
||||
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
|
||||
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
|
||||
|
||||
@@ -120,6 +120,11 @@
|
||||
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Стандартный cron. По умолчанию каждые 5 минут.</value></data>
|
||||
<data name="Settings.Workers.LivePollerEnabled"><value>Лайв-сборщик включён</value></data>
|
||||
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Сборщик расписания включён</value></data>
|
||||
<data name="Settings.Workers.LivePollIntervalSeconds"><value>Интервал лайв-опроса (сек)</value></data>
|
||||
<data name="Settings.Workers.LivePollIntervalSeconds.Hint"><value>Пауза между циклами сбора лайв-котировок. По умолчанию 30 с.</value></data>
|
||||
<data name="Settings.Workers.ResultsPollerEnabled"><value>Сборщик результатов включён</value></data>
|
||||
<data name="Settings.Workers.ResultsPollerEnabled.Hint"><value>Отключён до Phase 8. Включите только после реализации опроса match-complete.</value></data>
|
||||
<data name="Settings.Workers.ResultsPollIntervalSeconds"><value>Интервал сборщика результатов (сек)</value></data>
|
||||
|
||||
<!-- Settings — Storage -->
|
||||
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
|
||||
|
||||
@@ -16,4 +16,22 @@ public sealed class WorkerOptions
|
||||
|
||||
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
|
||||
public bool UpcomingPollerEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How long the live odds poller sleeps between cycles, in seconds.
|
||||
/// Default: 30 s.
|
||||
/// </summary>
|
||||
public int LivePollIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the results watch-list poller is enabled.
|
||||
/// Default: false — disabled until Phase 8 is complete.
|
||||
/// </summary>
|
||||
public bool ResultsPollerEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// How long the results watch-list poller sleeps between cycles, in seconds.
|
||||
/// Default: 300 s (5 minutes).
|
||||
/// </summary>
|
||||
public int ResultsPollIntervalSeconds { get; set; } = 300;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user