# Phase 4: Application Layer + Background Workers **Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend **Depends on:** Phase 1 (Domain), Phase 2 (Storage), Phase 3 (Scraping) ## Objective Wire scraping + storage together via use-case orchestrators in the Application layer and background services that execute pollers on configurable intervals. ## Tasks - [x] Implement use cases in `Marathon.Application/UseCases/`: - `PullUpcomingEventsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)` - `ExecuteAsync(CancellationToken)` → fetch upcoming events, persist new ones, capture initial pre-match snapshots for each - `PullLiveOddsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)` - `ExecuteAsync(CancellationToken)` → for each currently-live event, fetch a fresh snapshot, persist it - `PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)` - `ExecuteAsync(DateRange range, IReadOnlyList? selection, CancellationToken)` → fetch results for completed events (all or selected) - `ExportToExcelUseCase(IExcelExporter, IOptions, ILogger)` - `ExecuteAsync(DateRange, ExportKind, CancellationToken)` - [x] Implement background services in `Marathon.Infrastructure/Workers/`: - `UpcomingEventsPoller : BackgroundService` — runs `PullUpcomingEventsUseCase` on a configurable cron-like schedule (default: every 6 hours, Cronos 6-field) - `LiveOddsPoller : BackgroundService` — runs `PullLiveOddsUseCase` every `WorkerOptions.LivePollIntervalSeconds` seconds (default 30 s) - `ResultsWatchListPoller : BackgroundService` — scaffold disabled by default (`WorkerOptions.ResultsPollerEnabled = false`); formal impl in Phase 8 - All honor `CancellationToken`, log via `ILogger`, skip cycles gracefully on errors - [x] Add `WorkerOptions` POCO bound to `Workers:*` config (in `Marathon.Infrastructure.Configuration`; UI mirror in `Marathon.UI.Services`): `UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`, `LivePollIntervalSeconds`, `ResultsPollerEnabled`, `ResultsPollIntervalSeconds` - [x] Add `ApplicationModule.AddMarathonApplication(IServiceCollection)` in `Marathon.Application/ApplicationModule.cs` — no `IConfiguration` needed - [x] Add `InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration)` in `Marathon.Infrastructure/InfrastructureModule.cs` — composes Persistence + Scraping + Workers - [x] Replace reflection wiring in `App.xaml.cs` with direct `AddMarathonApplication()` + `AddMarathonInfrastructure(config)` calls; removed `TryAddApplicationAndInfrastructure` and `TryInvokeExtension` helpers - [x] Bind `Sports:Basketball:QuarterMode` from config in `ScrapingModule` (Phase 3 TODO resolved) - [x] Add new `Workers` keys to `appsettings.json` + `SharedResource.*.resx` + `Settings.razor` - [x] Tests in `Marathon.Application.Tests/UseCases/`: - Mock `IOddsScraper` + repos with NSubstitute - `PullUpcomingEventsUseCaseTests`: persists new events, skips duplicates, tolerates snapshot failures - `PullLiveOddsUseCaseTests`: one snapshot per live event, survives per-event errors - `PullResultsUseCaseTests`: selection filter, null=all-in-range, idempotency, persists scraped results - `ExportToExcelUseCaseTests`: delegates to exporter with correct args, propagates exporter exceptions - [x] Tests in `Marathon.Infrastructure.Tests/Workers/`: - `LiveOddsPollerTests`: happy-path invokes use case; disabled flag skips use case; exception-swallowing (continues running after use-case error) ## Files to Modify/Create - `src/Marathon.Application/UseCases/*.cs` - `src/Marathon.Application/DependencyInjection.cs` - `src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs` - `src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs` - `src/Marathon.Infrastructure/Configuration/WorkerOptions.cs` - `tests/Marathon.Application.Tests/UseCases/**` - `tests/Marathon.Infrastructure.Tests/Workers/**` ## Acceptance Criteria - Compiles (Big Bang). - Use cases depend only on Application abstractions (no Infrastructure refs). - Workers honor cancellation and don't crash on transient errors. - All variable timing/enabling is configurable. ## Notes - Use `IHostedService` from `Microsoft.Extensions.Hosting` — works in WPF host via `Host.CreateApplicationBuilder()` pattern (Phase 5 will expose this). - For the cron-style upcoming poller, prefer the `Cronos` package (small, mature) over hand-rolled scheduling. - Big Bang: compile-only smoke check. ## Review Checklist - [x] Use cases have no Infrastructure dependencies - [x] All three pollers configurable (interval, enable/disable) - [x] Cancellation propagated correctly (OperationCanceledException re-thrown, breaks loop) - [x] Errors logged, not propagated out of `ExecuteAsync` ## Handoff to Next Phase ### For Phase 6 (Event Browsing UI) #### Use case names, namespaces, and DI lifetimes All use cases are in `Marathon.Application.UseCases`, registered `Scoped`: | Class | `ExecuteAsync` signature | Return type | |---|---|---| | `PullUpcomingEventsUseCase` | `(CancellationToken)` | `(int EventsProcessed, int NewEvents, int SnapshotsCaptured)` | | `PullLiveOddsUseCase` | `(CancellationToken)` | `int` (snapshots captured) | | `PullResultsUseCase` | `(DateRange, IReadOnlyList?, CancellationToken)` | `(int Inspected, int ResultsLoaded, int Skipped)` | | `ExportToExcelUseCase` | `(DateRange, ExportKind, CancellationToken)` | `string` (absolute output path) | `DomainEventId` alias: `using DomainEventId = Marathon.Domain.ValueObjects.EventId;` (needed to disambiguate from `Microsoft.Extensions.Logging.EventId`). #### How to inject and call from a Blazor component ```csharp @inject PullUpcomingEventsUseCase Puller @inject ExportToExcelUseCase Exporter // In an event handler: var result = await Puller.ExecuteAsync(CancellationToken.None); // result.EventsProcessed, result.NewEvents, result.SnapshotsCaptured var path = await Exporter.ExecuteAsync(range, ExportKind.Combined, CancellationToken.None); ``` **Important caveat:** Use cases are `Scoped`. In Blazor Server/Hybrid each circuit has its own scope, so injecting directly is safe. Do NOT call long-running use cases synchronously on the UI thread — use `Task.Run` or await with a progress indicator. Ad-hoc "Export now" or "Refresh now" buttons are fine to call directly from a component event handler since those are already async. #### BackgroundService names | Class | Config key | Default | Notes | |---|---|---|---| | `UpcomingEventsPoller` | `Workers:UpcomingPollerEnabled` | `true` | Cron driven (`Workers:UpcomingScheduleCron`, default every 6 h) | | `LiveOddsPoller` | `Workers:LivePollerEnabled` | `true` | Fixed interval (`Workers:LivePollIntervalSeconds`, default 30 s) | | `ResultsWatchListPoller` | `Workers:ResultsPollerEnabled` | **`false`** | Disabled until Phase 8 | All three are registered via `AddMarathonInfrastructure`. They start automatically with the `IHost`. No manual wiring needed. #### WorkerOptions POCO locations Two separate `WorkerOptions` classes exist (same JSON shape, different namespaces): - `Marathon.Infrastructure.Configuration.WorkerOptions` — used by workers (immutable `init` setters) - `Marathon.UI.Services.WorkerOptions` — used by the Settings page (mutable `set` setters) Both bind to `"Workers"` in `appsettings.json`. Phase 6 can read live values via `IOptionsMonitor` (already registered by `AddMarathonUi`). #### ApplicationModule entry point ```csharp services.AddMarathonApplication(); // no IConfiguration needed services.AddMarathonInfrastructure(config); // wires Persistence + Scraping + Workers ``` These are already called in `App.xaml.cs`. Phase 6 needs no changes to DI setup. #### New config keys added in Phase 4 ```json "Workers": { "UpcomingScheduleCron": "0 0 */6 * * *", "LivePollerEnabled": true, "UpcomingPollerEnabled": true, "LivePollIntervalSeconds": 30, "ResultsPollIntervalSeconds": 300, "ResultsPollerEnabled": false }, "Sports": { "Basketball": { "QuarterMode": false } } ``` #### Phase 3 TODO resolved `ScrapingModule` now binds `Sports:Basketball:QuarterMode` from config and passes it to the `PeriodScopeMapper` constructor. The TODO comment is removed. #### Tests added - `Marathon.Application.Tests`: 14 new tests (1 placeholder → 15 total) covering all 4 use cases. - `Marathon.Infrastructure.Tests`: 3 new worker tests (77 → 80 total). - Total suite: 185 → **202 passing**.