Files
maraphon-app/plans/initial-implementation/phase-4-application-and-workers.md
alexei.dolgolyov 2acbaa5b77 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).
2026-05-05 12:28:15 +03:00

183 lines
8.5 KiB
Markdown

# 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<EventId>? selection, CancellationToken)`
→ fetch results for completed events (all or selected)
- `ExportToExcelUseCase(IExcelExporter, IOptions<StorageOptions>, 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<T>`, 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<DomainEventId>?, 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<Marathon.UI.Services.WorkerOptions>` (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**.