mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add weekend ingestion orchestration
This commit is contained in:
@@ -19,7 +19,7 @@ func main() {
|
|||||||
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
|
webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
|
||||||
port := flag.Int("port", 8080, "Port for web server (used with --web)")
|
port := flag.Int("port", 8080, "Port for web server (used with --web)")
|
||||||
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
|
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
|
||||||
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest OpenF1 sessions for a meeting key")
|
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
|
||||||
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
|
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
|
||||||
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
|
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
|
||||||
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")
|
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")
|
||||||
|
|||||||
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal file
40
documentations/refactor/20-phase-12-data-library-ui.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 12 Data Library UI
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The app can now ingest full weekends and browse local seasons, meetings, and
|
||||||
|
sessions. Phase 12 should make local data health visible in the Web UI so users
|
||||||
|
can understand what is stored, what is partial, and what command to run next.
|
||||||
|
|
||||||
|
This is a frontend slice. Keep it practical and built on the APIs already
|
||||||
|
available.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Add a Data Library style surface that shows:
|
||||||
|
|
||||||
|
- local seasons and meetings;
|
||||||
|
- sessions per meeting;
|
||||||
|
- dataset coverage per session;
|
||||||
|
- clear empty states;
|
||||||
|
- suggested CLI commands for ingestion/backfill.
|
||||||
|
|
||||||
|
This can be a new route or a tab/section reachable from the existing Race Hub
|
||||||
|
shell, depending on the current router structure.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not fetch OpenF1 from React.
|
||||||
|
- Do not trigger ingestion from the browser.
|
||||||
|
- Keep Race Hub direct links working.
|
||||||
|
- Reuse existing local navigation APIs unless a small backend gap is genuinely
|
||||||
|
blocking.
|
||||||
|
- Keep styling dense, operational, and restrained.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- User can inspect local data coverage without opening a specific session.
|
||||||
|
- Partial weekends/sessions are visibly distinct from complete ones.
|
||||||
|
- Empty database state explains the relevant CLI command.
|
||||||
|
- Existing Race Hub navigation continues to work.
|
||||||
|
- Frontend tests/build/e2e pass.
|
||||||
@@ -73,8 +73,10 @@ not implementation tickets yet.
|
|||||||
adding local-first season/weekend navigation around Race Hub.
|
adding local-first season/weekend navigation around Race Hub.
|
||||||
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
|
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
|
||||||
slice for making one command ingest a whole race weekend into the local DB.
|
slice for making one command ingest a whole race weekend into the local DB.
|
||||||
- [Cursor Phase 11 Prompt](cursor-phase-11-weekend-ingestion-prompt.md):
|
- [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice
|
||||||
current handoff prompt for the next backend phase.
|
for showing local ingestion coverage and next CLI actions.
|
||||||
|
- [Cursor Phase 12 Prompt](cursor-phase-12-data-library-ui-prompt.md): current
|
||||||
|
handoff prompt for the next frontend phase.
|
||||||
|
|
||||||
## External References
|
## External References
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# Prompt For Cursor: Phase 11 Weekend Ingestion
|
|
||||||
|
|
||||||
You are working in the `box-box` repository on Phase 11. The Web UI can now
|
|
||||||
browse local years, meetings, and sessions. Your task is to make the CLI able
|
|
||||||
to populate a whole race weekend/meeting in one backend ingestion flow.
|
|
||||||
|
|
||||||
## Read First
|
|
||||||
|
|
||||||
Open these files first:
|
|
||||||
|
|
||||||
- `documentations/refactor/19-phase-11-weekend-ingestion.md`
|
|
||||||
- `cmd/main.go`
|
|
||||||
- `internal/ingest/ingest.go`
|
|
||||||
- `internal/ingest/ingest_test.go`
|
|
||||||
- `internal/ingest/openf1.go`
|
|
||||||
- `internal/store/meetings.go`
|
|
||||||
- `internal/query/navigation.go`
|
|
||||||
|
|
||||||
Only open older docs if you are blocked.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Make meeting/weekend ingestion useful for the local-first Web UI. A user should
|
|
||||||
be able to ingest a meeting and have all sessions for that meeting populated
|
|
||||||
with Race Hub datasets where available.
|
|
||||||
|
|
||||||
## Current Shape
|
|
||||||
|
|
||||||
The project already has:
|
|
||||||
|
|
||||||
- `--ingest-year`
|
|
||||||
- `--ingest-meeting`
|
|
||||||
- `--ingest-session`
|
|
||||||
- session-level Race Hub ingestion datasets;
|
|
||||||
- local navigation APIs and UI that depend on ingested meeting/session data.
|
|
||||||
|
|
||||||
Confirm the exact current behavior before editing. If `--ingest-meeting`
|
|
||||||
currently only stores meeting/session metadata, extend it or add a clearly named
|
|
||||||
flag. Prefer the least surprising CLI behavior.
|
|
||||||
|
|
||||||
## Work To Do
|
|
||||||
|
|
||||||
1. Add meeting/weekend orchestration that fetches sessions for a meeting and
|
|
||||||
ingests Race Hub datasets for each session.
|
|
||||||
2. Preserve single-session ingestion behavior.
|
|
||||||
3. Return/report per-session summaries clearly.
|
|
||||||
4. Keep raw payload provenance for all fetched endpoints.
|
|
||||||
5. Make partial failures visible without erasing successful session data.
|
|
||||||
6. Add focused offline tests with fake sources.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Do not fetch OpenF1 from React.
|
|
||||||
- Do not introduce background ingestion from normal page views.
|
|
||||||
- Do not persist high-volume car telemetry in this phase.
|
|
||||||
- Do not break existing e2e seed behavior.
|
|
||||||
- Keep completed historical sessions as the default mental model.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/ingest/... ./internal/store/... ./internal/query/... ./internal/web/...
|
|
||||||
go build -o /private/tmp/box-box ./cmd/main.go
|
|
||||||
cd frontend && npm test -- --run
|
|
||||||
cd frontend && npm run build
|
|
||||||
npm run test:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
## Report Back
|
|
||||||
|
|
||||||
Summarize:
|
|
||||||
|
|
||||||
- files changed;
|
|
||||||
- CLI behavior added or changed;
|
|
||||||
- tests run and results;
|
|
||||||
- follow-up risks, especially around OpenF1 rate limits or partial sessions.
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Prompt For Cursor: Phase 12 Data Library UI
|
||||||
|
|
||||||
|
You are working in the `box-box` repository on Phase 12. Build a practical Web
|
||||||
|
UI surface for inspecting local data coverage and ingestion status.
|
||||||
|
|
||||||
|
## Read First
|
||||||
|
|
||||||
|
Open these files first:
|
||||||
|
|
||||||
|
- `documentations/refactor/20-phase-12-data-library-ui.md`
|
||||||
|
- `frontend/src/components/LocalDataNavigator.tsx`
|
||||||
|
- `frontend/src/pages/RaceHubPage.tsx`
|
||||||
|
- `frontend/src/api.ts`
|
||||||
|
- `frontend/src/types.ts`
|
||||||
|
- `frontend/src/main.tsx`
|
||||||
|
- `frontend/src/styles/app.css`
|
||||||
|
- `tests/race-hub.spec.ts`
|
||||||
|
|
||||||
|
Only open older docs if you are blocked.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a user inspect what is in the local database and understand what is missing
|
||||||
|
without needing to open every Race Hub session manually.
|
||||||
|
|
||||||
|
## APIs Available
|
||||||
|
|
||||||
|
- `GET /api/v1/seasons`
|
||||||
|
- `GET /api/v1/meetings?year=<year>&source=local`
|
||||||
|
- `GET /api/v1/weekend?meeting_key=<key>`
|
||||||
|
- `GET /api/v1/race-hub?session_key=<key>`
|
||||||
|
|
||||||
|
Do not fetch OpenF1 from React.
|
||||||
|
|
||||||
|
## Work To Do
|
||||||
|
|
||||||
|
1. Add a Data Library route or reachable section in the React app.
|
||||||
|
2. Show local years/meetings/sessions and dataset coverage.
|
||||||
|
3. Make partial vs complete sessions visually clear.
|
||||||
|
4. Include copyable/reference CLI commands, such as:
|
||||||
|
- `box-box --ingest-year 2025`
|
||||||
|
- `box-box --ingest-meeting <meeting_key>`
|
||||||
|
- `box-box --ingest-session <session_key>`
|
||||||
|
5. Preserve direct Race Hub navigation.
|
||||||
|
6. Add focused frontend tests and update e2e if stable.
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
- Keep it operational and table-like.
|
||||||
|
- Avoid decorative cards and generic dashboard clutter.
|
||||||
|
- Use the existing dataset/status visual language where possible.
|
||||||
|
- Mobile should remain usable.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm test -- --run
|
||||||
|
cd frontend && npm run build
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report Back
|
||||||
|
|
||||||
|
Summarize:
|
||||||
|
|
||||||
|
- files changed;
|
||||||
|
- Data Library behavior added;
|
||||||
|
- tests run and results;
|
||||||
|
- follow-up API or design gaps.
|
||||||
@@ -31,26 +31,34 @@ func DefaultOptions() Options {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionSummary captures the outcome of ingesting one session within a meeting run.
|
||||||
|
type SessionSummary struct {
|
||||||
|
SessionKey int `json:"session_key"`
|
||||||
|
SessionName string `json:"session_name,omitempty"`
|
||||||
|
Summary Summary `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
// Summary captures the outcome of an ingestion run.
|
// Summary captures the outcome of an ingestion run.
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
ScopeType string `json:"scope_type"`
|
ScopeType string `json:"scope_type"`
|
||||||
ScopeKey string `json:"scope_key"`
|
ScopeKey string `json:"scope_key"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
Meetings int `json:"meetings"`
|
Meetings int `json:"meetings"`
|
||||||
Sessions int `json:"sessions"`
|
Sessions int `json:"sessions"`
|
||||||
Drivers int `json:"drivers"`
|
Drivers int `json:"drivers"`
|
||||||
SessionResults int `json:"session_results"`
|
SessionResults int `json:"session_results"`
|
||||||
StartingGrid int `json:"starting_grid"`
|
StartingGrid int `json:"starting_grid"`
|
||||||
Stints int `json:"stints"`
|
Stints int `json:"stints"`
|
||||||
PitStops int `json:"pit_stops"`
|
PitStops int `json:"pit_stops"`
|
||||||
Positions int `json:"positions"`
|
Positions int `json:"positions"`
|
||||||
RaceControl int `json:"race_control"`
|
RaceControl int `json:"race_control"`
|
||||||
Weather int `json:"weather"`
|
Weather int `json:"weather"`
|
||||||
Laps int `json:"laps"`
|
Laps int `json:"laps"`
|
||||||
RawPayloads int `json:"raw_payloads"`
|
RawPayloads int `json:"raw_payloads"`
|
||||||
RawInserted int `json:"raw_inserted"`
|
RawInserted int `json:"raw_inserted"`
|
||||||
Errors []string `json:"errors,omitempty"`
|
SessionSummaries []SessionSummary `json:"session_summaries,omitempty"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service orchestrates OpenF1-to-store ingestion workflows.
|
// Service orchestrates OpenF1-to-store ingestion workflows.
|
||||||
@@ -130,7 +138,7 @@ func (s *Service) IngestYear(year int) (Summary, error) {
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestMeeting fetches meeting metadata and all sessions for a meeting key.
|
// IngestMeeting fetches meeting metadata, all sessions, and Race Hub datasets for each session.
|
||||||
func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
|
func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
|
||||||
summary := Summary{
|
summary := Summary{
|
||||||
ScopeType: "meeting",
|
ScopeType: "meeting",
|
||||||
@@ -205,9 +213,37 @@ func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
|
|||||||
summary.Sessions++
|
summary.Sessions++
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.Status = statusForDryRun(s.opts.DryRun)
|
sessionFailures := 0
|
||||||
|
for _, sess := range sessions {
|
||||||
|
s.opts.Progress.Step("ingesting Race Hub datasets for session %d (%s)", sess.SessionKey, sess.SessionName)
|
||||||
|
sessSummary, err := s.ingestSessionDatasets(sess.SessionKey, meetingKey)
|
||||||
|
ss := SessionSummary{
|
||||||
|
SessionKey: sess.SessionKey,
|
||||||
|
SessionName: sess.SessionName,
|
||||||
|
Summary: sessSummary,
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
sessionFailures++
|
||||||
|
ss.Summary.Status = "failed"
|
||||||
|
ss.Summary.Errors = append(ss.Summary.Errors, err.Error())
|
||||||
|
summary.Errors = append(summary.Errors, fmt.Sprintf(
|
||||||
|
"session %d (%s): %v", sess.SessionKey, sess.SessionName, err,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
summary.SessionSummaries = append(summary.SessionSummaries, ss)
|
||||||
|
summary.mergeCounts(sessSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Status = meetingStatus(sessionFailures, len(sessions), s.opts.DryRun)
|
||||||
s.finishRun(runID, summary)
|
s.finishRun(runID, summary)
|
||||||
s.opts.Progress.Summary(summary)
|
s.opts.Progress.Summary(summary)
|
||||||
|
|
||||||
|
if sessionFailures > 0 {
|
||||||
|
return summary, fmt.Errorf(
|
||||||
|
"meeting %d: %d of %d session(s) failed",
|
||||||
|
meetingKey, sessionFailures, len(sessions),
|
||||||
|
)
|
||||||
|
}
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,28 +323,48 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
|||||||
summary.Sessions++
|
summary.Sessions++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
datasetSummary, err := s.ingestSessionDatasets(sessionKey, meetingKey)
|
||||||
|
summary.mergeCounts(datasetSummary)
|
||||||
|
if err != nil {
|
||||||
|
return s.finishFailed(runID, summary, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Status = statusForDryRun(s.opts.DryRun)
|
||||||
|
s.finishRun(runID, summary)
|
||||||
|
s.opts.Progress.Summary(summary)
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ingestSessionDatasets(sessionKey, meetingKey int) (Summary, error) {
|
||||||
|
summary := Summary{
|
||||||
|
ScopeType: "session",
|
||||||
|
ScopeKey: fmt.Sprintf("%d", sessionKey),
|
||||||
|
DryRun: s.opts.DryRun,
|
||||||
|
}
|
||||||
|
sk := sessionKey
|
||||||
|
|
||||||
s.opts.Progress.Step("fetching drivers for session %d", sessionKey)
|
s.opts.Progress.Step("fetching drivers for session %d", sessionKey)
|
||||||
driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) {
|
driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) {
|
||||||
return s.source.FetchDriversForSession(sessionKey)
|
return s.source.FetchDriversForSession(sessionKey)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
summary.RawPayloads++
|
summary.RawPayloads++
|
||||||
if !s.opts.DryRun {
|
if !s.opts.DryRun {
|
||||||
inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk)
|
inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if inserted {
|
if inserted {
|
||||||
summary.RawInserted++
|
summary.RawInserted++
|
||||||
}
|
}
|
||||||
for _, d := range drivers {
|
for _, d := range drivers {
|
||||||
if err := s.store.UpsertDriver(driverToStore(d)); err != nil {
|
if err := s.store.UpsertDriver(driverToStore(d)); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil {
|
if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
summary.Drivers++
|
summary.Drivers++
|
||||||
}
|
}
|
||||||
@@ -322,20 +378,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
|||||||
return s.source.FetchSessionResult(sessionKey)
|
return s.source.FetchSessionResult(sessionKey)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
summary.RawPayloads++
|
summary.RawPayloads++
|
||||||
if !s.opts.DryRun {
|
if !s.opts.DryRun {
|
||||||
inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk)
|
inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if inserted {
|
if inserted {
|
||||||
summary.RawInserted++
|
summary.RawInserted++
|
||||||
}
|
}
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil {
|
if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
summary.SessionResults++
|
summary.SessionResults++
|
||||||
}
|
}
|
||||||
@@ -349,20 +405,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
|||||||
return s.source.FetchStartingGrid(sessionKey)
|
return s.source.FetchStartingGrid(sessionKey)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
summary.RawPayloads++
|
summary.RawPayloads++
|
||||||
if !s.opts.DryRun {
|
if !s.opts.DryRun {
|
||||||
inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk)
|
inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if inserted {
|
if inserted {
|
||||||
summary.RawInserted++
|
summary.RawInserted++
|
||||||
}
|
}
|
||||||
for _, g := range grid {
|
for _, g := range grid {
|
||||||
if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil {
|
if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
summary.StartingGrid++
|
summary.StartingGrid++
|
||||||
}
|
}
|
||||||
@@ -372,30 +428,55 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
|||||||
s.delay()
|
s.delay()
|
||||||
|
|
||||||
if err := s.ingestStints(&summary, meetingKey, sk); err != nil {
|
if err := s.ingestStints(&summary, meetingKey, sk); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if err := s.ingestPitStops(&summary, meetingKey, sk); err != nil {
|
if err := s.ingestPitStops(&summary, meetingKey, sk); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if err := s.ingestPositions(&summary, meetingKey, sk); err != nil {
|
if err := s.ingestPositions(&summary, meetingKey, sk); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if err := s.ingestRaceControl(&summary, meetingKey, sk); err != nil {
|
if err := s.ingestRaceControl(&summary, meetingKey, sk); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if err := s.ingestWeather(&summary, meetingKey, sk); err != nil {
|
if err := s.ingestWeather(&summary, meetingKey, sk); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
if err := s.ingestLaps(&summary, meetingKey, sk); err != nil {
|
if err := s.ingestLaps(&summary, meetingKey, sk); err != nil {
|
||||||
return s.finishFailed(runID, summary, err)
|
return summary, err
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.Status = statusForDryRun(s.opts.DryRun)
|
summary.Status = statusForDryRun(s.opts.DryRun)
|
||||||
s.finishRun(runID, summary)
|
|
||||||
s.opts.Progress.Summary(summary)
|
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Summary) mergeCounts(other Summary) {
|
||||||
|
s.Drivers += other.Drivers
|
||||||
|
s.SessionResults += other.SessionResults
|
||||||
|
s.StartingGrid += other.StartingGrid
|
||||||
|
s.Stints += other.Stints
|
||||||
|
s.PitStops += other.PitStops
|
||||||
|
s.Positions += other.Positions
|
||||||
|
s.RaceControl += other.RaceControl
|
||||||
|
s.Weather += other.Weather
|
||||||
|
s.Laps += other.Laps
|
||||||
|
s.RawPayloads += other.RawPayloads
|
||||||
|
s.RawInserted += other.RawInserted
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingStatus(sessionFailures, sessionTotal int, dryRun bool) string {
|
||||||
|
if dryRun {
|
||||||
|
return "dry_run"
|
||||||
|
}
|
||||||
|
if sessionFailures == 0 {
|
||||||
|
return "completed"
|
||||||
|
}
|
||||||
|
if sessionFailures == sessionTotal {
|
||||||
|
return "failed"
|
||||||
|
}
|
||||||
|
return "partial"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) {
|
func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) {
|
||||||
if s.opts.DryRun {
|
if s.opts.DryRun {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type fakeSource struct {
|
|||||||
weather map[int][]models.Weather
|
weather map[int][]models.Weather
|
||||||
laps map[int][]models.Lap
|
laps map[int][]models.Lap
|
||||||
failOn string
|
failOn string
|
||||||
|
failSession map[int]string
|
||||||
liveLockout bool
|
liveLockout bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,16 @@ func (f *fakeSource) maybeFail(endpoint string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeSource) maybeFailSession(sessionKey int, endpoint string) error {
|
||||||
|
if err := f.maybeFail(endpoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.failSession != nil && f.failSession[sessionKey] == endpoint {
|
||||||
|
return errors.New("simulated fetch failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeSource) wrap(endpoint, requestKey string, payload any) FetchResult {
|
func (f *fakeSource) wrap(endpoint, requestKey string, payload any) FetchResult {
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
return FetchResult{
|
return FetchResult{
|
||||||
@@ -103,7 +114,7 @@ func (f *fakeSource) FetchSessionsForSessionKey(sessionKey int) (FetchResult, []
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) {
|
func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) {
|
||||||
if err := f.maybeFail("drivers"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "drivers"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.drivers[sessionKey]
|
data := f.drivers[sessionKey]
|
||||||
@@ -111,7 +122,7 @@ func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) {
|
func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) {
|
||||||
if err := f.maybeFail("session_result"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "session_result"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.results[sessionKey]
|
data := f.results[sessionKey]
|
||||||
@@ -119,7 +130,7 @@ func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.S
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) {
|
func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) {
|
||||||
if err := f.maybeFail("starting_grid"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "starting_grid"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.grid[sessionKey]
|
data := f.grid[sessionKey]
|
||||||
@@ -127,7 +138,7 @@ func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.St
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
|
func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
|
||||||
if err := f.maybeFail("stints"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "stints"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.stints[sessionKey]
|
data := f.stints[sessionKey]
|
||||||
@@ -135,7 +146,7 @@ func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []model
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
|
func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
|
||||||
if err := f.maybeFail("pit"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "pit"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.pitStops[sessionKey]
|
data := f.pitStops[sessionKey]
|
||||||
@@ -143,7 +154,7 @@ func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
|
func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
|
||||||
if err := f.maybeFail("position"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "position"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.positions[sessionKey]
|
data := f.positions[sessionKey]
|
||||||
@@ -151,7 +162,7 @@ func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
|
func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
|
||||||
if err := f.maybeFail("race_control"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "race_control"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.raceControl[sessionKey]
|
data := f.raceControl[sessionKey]
|
||||||
@@ -159,7 +170,7 @@ func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
|
func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
|
||||||
if err := f.maybeFail("weather"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "weather"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.weather[sessionKey]
|
data := f.weather[sessionKey]
|
||||||
@@ -167,7 +178,7 @@ func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeSource) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
|
func (f *fakeSource) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
|
||||||
if err := f.maybeFail("laps"); err != nil {
|
if err := f.maybeFailSession(sessionKey, "laps"); err != nil {
|
||||||
return FetchResult{}, nil, err
|
return FetchResult{}, nil, err
|
||||||
}
|
}
|
||||||
data := f.laps[sessionKey]
|
data := f.laps[sessionKey]
|
||||||
@@ -261,6 +272,45 @@ func testSessionFixtures() (int, int, *fakeSource) {
|
|||||||
return meetingKey, sessionKey, src
|
return meetingKey, sessionKey, src
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testWeekendFixtures() (int, int, int, *fakeSource) {
|
||||||
|
meetingKey, raceKey, src := testSessionFixtures()
|
||||||
|
const qualiKey = 9473
|
||||||
|
|
||||||
|
src.sessionsByMeeting[meetingKey] = []models.Session{
|
||||||
|
{SessionKey: qualiKey, MeetingKey: meetingKey, SessionName: "Qualifying", SessionType: "Qualifying", CircuitKey: 10},
|
||||||
|
{SessionKey: raceKey, MeetingKey: meetingKey, SessionName: "Race", SessionType: "Race", CircuitKey: 10},
|
||||||
|
}
|
||||||
|
src.sessionsByKey[qualiKey] = []models.Session{src.sessionsByMeeting[meetingKey][0]}
|
||||||
|
src.drivers[qualiKey] = []models.Driver{
|
||||||
|
{
|
||||||
|
DriverNumber: 1,
|
||||||
|
FullName: "Max Verstappen",
|
||||||
|
SessionKey: qualiKey,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
TeamName: "Red Bull Racing",
|
||||||
|
TeamColour: "3671C6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DriverNumber: 44,
|
||||||
|
FullName: "Lewis Hamilton",
|
||||||
|
SessionKey: qualiKey,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
TeamName: "Ferrari",
|
||||||
|
TeamColour: "E8002D",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
src.results[qualiKey] = []models.SessionResult{
|
||||||
|
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1},
|
||||||
|
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, GapToLeader: 0.2},
|
||||||
|
}
|
||||||
|
src.grid[qualiKey] = []models.StartingGrid{
|
||||||
|
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.1},
|
||||||
|
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.3},
|
||||||
|
}
|
||||||
|
|
||||||
|
return meetingKey, qualiKey, raceKey, src
|
||||||
|
}
|
||||||
|
|
||||||
func ptrFloat64(v float64) *float64 {
|
func ptrFloat64(v float64) *float64 {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
@@ -476,3 +526,82 @@ func TestIngestYearAndMeeting(t *testing.T) {
|
|||||||
t.Fatalf("meeting sessions = %d, want 1", meetingSummary.Sessions)
|
t.Fatalf("meeting sessions = %d, want 1", meetingSummary.Sessions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIngestMeetingWeekendIngestsAllSessions(t *testing.T) {
|
||||||
|
meetingKey, qualiKey, raceKey, src := testWeekendFixtures()
|
||||||
|
st := openTestStore(t)
|
||||||
|
|
||||||
|
opts := DefaultOptions()
|
||||||
|
opts.RequestDelay = 0
|
||||||
|
svc := NewService(st, src, opts)
|
||||||
|
|
||||||
|
summary, err := svc.IngestMeeting(meetingKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IngestMeeting() error = %v", err)
|
||||||
|
}
|
||||||
|
if summary.Status != "completed" {
|
||||||
|
t.Fatalf("summary.Status = %q, want completed", summary.Status)
|
||||||
|
}
|
||||||
|
if summary.Sessions != 2 {
|
||||||
|
t.Fatalf("summary.Sessions = %d, want 2", summary.Sessions)
|
||||||
|
}
|
||||||
|
if len(summary.SessionSummaries) != 2 {
|
||||||
|
t.Fatalf("session summaries = %d, want 2", len(summary.SessionSummaries))
|
||||||
|
}
|
||||||
|
if summary.Drivers != 4 {
|
||||||
|
t.Fatalf("summary.Drivers = %d, want 4 (2 per session)", summary.Drivers)
|
||||||
|
}
|
||||||
|
if summary.SessionResults != 4 {
|
||||||
|
t.Fatalf("summary.SessionResults = %d, want 4", summary.SessionResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sk := range []int{qualiKey, raceKey} {
|
||||||
|
drivers, err := st.ListSessionDrivers(sk)
|
||||||
|
if err != nil || len(drivers) != 2 {
|
||||||
|
t.Fatalf("session %d drivers = %+v, err = %v, want 2", sk, drivers, err)
|
||||||
|
}
|
||||||
|
results, err := st.ListSessionResults(sk)
|
||||||
|
if err != nil || len(results) != 2 {
|
||||||
|
t.Fatalf("session %d results = %+v, err = %v, want 2", sk, results, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestMeetingPartialFailurePreservesSuccessfulSessions(t *testing.T) {
|
||||||
|
meetingKey, qualiKey, raceKey, src := testWeekendFixtures()
|
||||||
|
src.failSession = map[int]string{qualiKey: "session_result"}
|
||||||
|
st := openTestStore(t)
|
||||||
|
|
||||||
|
opts := DefaultOptions()
|
||||||
|
opts.RequestDelay = 0
|
||||||
|
svc := NewService(st, src, opts)
|
||||||
|
|
||||||
|
summary, err := svc.IngestMeeting(meetingKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("IngestMeeting() expected partial failure error, got nil")
|
||||||
|
}
|
||||||
|
if summary.Status != "partial" {
|
||||||
|
t.Fatalf("summary.Status = %q, want partial", summary.Status)
|
||||||
|
}
|
||||||
|
if len(summary.Errors) != 1 {
|
||||||
|
t.Fatalf("summary.Errors = %v, want 1 entry", summary.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
raceDrivers, err := st.ListSessionDrivers(raceKey)
|
||||||
|
if err != nil || len(raceDrivers) != 2 {
|
||||||
|
t.Fatalf("race session drivers = %+v, err = %v, want 2 preserved", raceDrivers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
qualiResults, err := st.ListSessionResults(qualiKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSessionResults(quali) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(qualiResults) != 0 {
|
||||||
|
t.Fatalf("quali session_results after failure = %d, want 0", len(qualiResults))
|
||||||
|
}
|
||||||
|
|
||||||
|
meetings, err := st.ListMeetingsByYear(2025)
|
||||||
|
if err != nil || len(meetings) != 1 {
|
||||||
|
t.Fatalf("meetings after partial failure = %+v, err = %v, want 1 meeting preserved", meetings, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,9 +40,40 @@ func (p *Progress) Summary(summary Summary) {
|
|||||||
if summary.StartingGrid > 0 {
|
if summary.StartingGrid > 0 {
|
||||||
fmt.Fprintf(p.w, " starting grid: %d\n", summary.StartingGrid)
|
fmt.Fprintf(p.w, " starting grid: %d\n", summary.StartingGrid)
|
||||||
}
|
}
|
||||||
|
if summary.Stints > 0 {
|
||||||
|
fmt.Fprintf(p.w, " stints: %d\n", summary.Stints)
|
||||||
|
}
|
||||||
|
if summary.PitStops > 0 {
|
||||||
|
fmt.Fprintf(p.w, " pit stops: %d\n", summary.PitStops)
|
||||||
|
}
|
||||||
|
if summary.Positions > 0 {
|
||||||
|
fmt.Fprintf(p.w, " positions: %d\n", summary.Positions)
|
||||||
|
}
|
||||||
|
if summary.RaceControl > 0 {
|
||||||
|
fmt.Fprintf(p.w, " race control: %d\n", summary.RaceControl)
|
||||||
|
}
|
||||||
|
if summary.Weather > 0 {
|
||||||
|
fmt.Fprintf(p.w, " weather: %d\n", summary.Weather)
|
||||||
|
}
|
||||||
|
if summary.Laps > 0 {
|
||||||
|
fmt.Fprintf(p.w, " laps: %d\n", summary.Laps)
|
||||||
|
}
|
||||||
if summary.RawPayloads > 0 {
|
if summary.RawPayloads > 0 {
|
||||||
fmt.Fprintf(p.w, " raw payloads fetched: %d (inserted: %d)\n", summary.RawPayloads, summary.RawInserted)
|
fmt.Fprintf(p.w, " raw payloads fetched: %d (inserted: %d)\n", summary.RawPayloads, summary.RawInserted)
|
||||||
}
|
}
|
||||||
|
for _, ss := range summary.SessionSummaries {
|
||||||
|
fmt.Fprintf(p.w, " session %d (%s): status=%s", ss.SessionKey, ss.SessionName, ss.Summary.Status)
|
||||||
|
if ss.Summary.Drivers > 0 || ss.Summary.SessionResults > 0 {
|
||||||
|
fmt.Fprintf(p.w, " drivers=%d results=%d", ss.Summary.Drivers, ss.Summary.SessionResults)
|
||||||
|
}
|
||||||
|
if ss.Summary.RawPayloads > 0 {
|
||||||
|
fmt.Fprintf(p.w, " raw=%d", ss.Summary.RawPayloads)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(p.w)
|
||||||
|
for _, errMsg := range ss.Summary.Errors {
|
||||||
|
fmt.Fprintf(p.w, " error: %s\n", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, errMsg := range summary.Errors {
|
for _, errMsg := range summary.Errors {
|
||||||
fmt.Fprintf(p.w, " error: %s\n", errMsg)
|
fmt.Fprintf(p.w, " error: %s\n", errMsg)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user