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:
@@ -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 |
|
||||
|
||||
@@ -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**.
|
||||
|
||||
Reference in New Issue
Block a user