2acbaa5b77
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).
183 lines
8.5 KiB
Markdown
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**.
|