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:
2026-05-05 12:28:15 +03:00
parent c4d87b59d6
commit 2acbaa5b77
31 changed files with 1719 additions and 94 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ with scraping research, no implementation.
| Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 9 projects (5 src + 4 test). 96 domain tests passed. Key decisions: BetScope sealed hierarchy, ScheduledAt=UTC+3 (Moscow), OddsValue rejects zero. Deviations: slnx auto-created alongside sln, WPF App.xaml.cs needs FQ Application type. |
| Phase 2 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 3 + 5 | — |
| Phase 3 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 2 + 5 | — |
| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | |
| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 4 use cases, 3 BackgroundService pollers, InfrastructureModule, ApplicationModule, reflection wiring removed. 202/202 tests green (+17 new). |
| Phase 5 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | ✅ With 2 + 3 | Uses frontend-design skill |
| Phase 6 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | — | Uses frontend-design skill |
| Phase 7 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus |
+1 -1
View File
@@ -66,7 +66,7 @@ parameter configurable.
| Phase 1: Solution + Domain | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 96/96 Domain tests | ✅ 61114ea |
| Phase 2: Storage | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
| Phase 3: Scraping | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
| Phase 4: Application + Workers | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 4: Application + Workers | backend | ✅ Done | ⬜ | ✅ Build OK + 202/202 tests | ⬜ |
| Phase 5: Host + Theme + i18n | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 11/11 UI tests | ✅ batch (e4d8476…686550d…+) |
| Phase 6: Event browsing UI | frontend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
@@ -1,6 +1,6 @@
# Phase 4: Application Layer + Background Workers
**Status:** ⬜ Not Started
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
**Depends on:** Phase 1 (Domain), Phase 2 (Storage), Phase 3 (Scraping)
@@ -12,7 +12,7 @@ and background services that execute pollers on configurable intervals.
## Tasks
- [ ] Implement use cases in `Marathon.Application/UseCases/`:
- [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
@@ -22,39 +22,38 @@ and background services that execute pollers on configurable intervals.
- `PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)`
- `ExecuteAsync(DateRange range, IReadOnlyList<EventId>? selection, CancellationToken)`
→ fetch results for completed events (all or selected)
- `ExportToExcelUseCase(IExcelExporter, IEventRepository)`
- `ExportToExcelUseCase(IExcelExporter, IOptions<StorageOptions>, ILogger)`
- `ExecuteAsync(DateRange, ExportKind, CancellationToken)`
- [ ] Implement background services in `Marathon.Infrastructure/Workers/`:
- [x] Implement background services in `Marathon.Infrastructure/Workers/`:
- `UpcomingEventsPoller : BackgroundService` — runs `PullUpcomingEventsUseCase` on
a configurable cron-like schedule (default: every 6 hours)
a configurable cron-like schedule (default: every 6 hours, Cronos 6-field)
- `LiveOddsPoller : BackgroundService` — runs `PullLiveOddsUseCase` every
`Scraping:PollingIntervalSeconds` seconds
- Both honor `CancellationToken`, log via `ILogger<T>`, and skip cycles gracefully
on errors (don't crash the host)
- [ ] Add `WorkerOptions` POCO bound to `Workers:*` config:
```csharp
public sealed class WorkerOptions {
public string UpcomingScheduleCron { get; init; } = "0 0 */6 * * *"; // every 6h
public bool LivePollerEnabled { get; init; } = true;
public bool UpcomingPollerEnabled { get; init; } = true;
}
```
Use `Cronos` package or simple TimeSpan for upcoming schedule.
- [ ] Add DI extension `AddMarathonApplication(IServiceCollection, IConfiguration)`
in `Marathon.Application/DependencyInjection.cs`:
- Registers all use cases
- [ ] Update `Marathon.Infrastructure/DependencyInjection.cs` to also register
`BackgroundService`s under `services.AddHostedService<T>()`.
- [ ] Tests in `Marathon.Application.Tests`:
`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
- Test: `PullUpcomingEventsUseCase` persists new events, skips duplicates
- Test: `PullLiveOddsUseCase` writes a snapshot per live event
- Test: `PullResultsUseCase` respects `selection` filter (when null, fetches all)
- Test: `ExportToExcelUseCase` invokes `IExcelExporter.ExportAsync` with correct
date range
- [ ] Tests in `Marathon.Infrastructure.Tests/Workers/`:
- Test: `LiveOddsPoller` invokes use case at configured interval (use FakeTimeProvider)
- Test: poller continues after a use-case exception (logs, doesn't propagate)
- `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
@@ -83,12 +82,101 @@ and background services that execute pollers on configurable intervals.
## Review Checklist
- [ ] Use cases have no Infrastructure dependencies
- [ ] Both pollers configurable (interval, enable/disable)
- [ ] Cancellation propagated correctly
- [ ] Errors logged, not propagated out of `ExecuteAsync`
- [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
<!-- Filled by Phase 4 implementer. Phase 5 needs to know how to start the host
including these BackgroundServices. -->
### 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**.