mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
1011 lines
28 KiB
Go
1011 lines
28 KiB
Go
package ingest
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/api"
|
|
"github.com/AmanTahiliani/box-box/internal/models"
|
|
"github.com/AmanTahiliani/box-box/internal/store"
|
|
)
|
|
|
|
// Options configures ingestion behavior.
|
|
type Options struct {
|
|
DryRun bool
|
|
Force bool // Re-fetch even if session_coverage says 'complete'
|
|
RequestDelay time.Duration
|
|
MaxRetries int
|
|
RetryBackoff time.Duration
|
|
Progress *Progress
|
|
}
|
|
|
|
// DefaultOptions returns conservative ingestion defaults.
|
|
func DefaultOptions() Options {
|
|
return Options{
|
|
RequestDelay: 300 * time.Millisecond,
|
|
MaxRetries: 5,
|
|
RetryBackoff: 500 * time.Millisecond,
|
|
Progress: NewProgress(nil),
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
type Summary struct {
|
|
ScopeType string `json:"scope_type"`
|
|
ScopeKey string `json:"scope_key"`
|
|
Status string `json:"status"`
|
|
DryRun bool `json:"dry_run"`
|
|
Meetings int `json:"meetings"`
|
|
Sessions int `json:"sessions"`
|
|
Drivers int `json:"drivers"`
|
|
SessionResults int `json:"session_results"`
|
|
StartingGrid int `json:"starting_grid"`
|
|
Stints int `json:"stints"`
|
|
PitStops int `json:"pit_stops"`
|
|
Positions int `json:"positions"`
|
|
RaceControl int `json:"race_control"`
|
|
Weather int `json:"weather"`
|
|
Laps int `json:"laps"`
|
|
RawPayloads int `json:"raw_payloads"`
|
|
RawInserted int `json:"raw_inserted"`
|
|
SessionSummaries []SessionSummary `json:"session_summaries,omitempty"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// Service orchestrates OpenF1-to-store ingestion workflows.
|
|
type Service struct {
|
|
store *store.Store
|
|
source Source
|
|
opts Options
|
|
}
|
|
|
|
// NewService creates an ingestion service.
|
|
func NewService(st *store.Store, source Source, opts Options) *Service {
|
|
if opts.MaxRetries <= 0 {
|
|
opts.MaxRetries = 5
|
|
}
|
|
if opts.RetryBackoff <= 0 {
|
|
opts.RetryBackoff = 500 * time.Millisecond
|
|
}
|
|
if opts.RequestDelay <= 0 {
|
|
opts.RequestDelay = 300 * time.Millisecond
|
|
}
|
|
if opts.Progress == nil {
|
|
opts.Progress = NewProgress(nil)
|
|
}
|
|
return &Service{store: st, source: source, opts: opts}
|
|
}
|
|
|
|
// IngestYear fetches and stores all meetings for a season year.
|
|
func (s *Service) IngestYear(year int) (Summary, error) {
|
|
summary := Summary{
|
|
ScopeType: "year",
|
|
ScopeKey: fmt.Sprintf("%d", year),
|
|
DryRun: s.opts.DryRun,
|
|
}
|
|
if year < 2023 {
|
|
return summary, fmt.Errorf("invalid year %d: must be 2023 or later", year)
|
|
}
|
|
|
|
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
|
|
if err != nil {
|
|
return summary, err
|
|
}
|
|
|
|
s.opts.Progress.Step("fetching meetings for %d", year)
|
|
fetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
|
|
return s.source.FetchMeetingsForYear(year)
|
|
})
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
inserted, err := s.storeRaw(fetch, nil, nil)
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
}
|
|
s.delay()
|
|
|
|
for _, m := range meetings {
|
|
if s.opts.DryRun {
|
|
summary.Meetings++
|
|
continue
|
|
}
|
|
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.Meetings++
|
|
}
|
|
|
|
sessionFailures := 0
|
|
partialSessions := 0
|
|
var totalSessionsCount int
|
|
|
|
for _, m := range meetings {
|
|
s.opts.Progress.Step("fetching sessions for meeting %d (%s)", m.MeetingKey, m.MeetingName)
|
|
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
|
|
return s.source.FetchSessionsForMeeting(int(m.MeetingKey))
|
|
})
|
|
if err != nil {
|
|
summary.Errors = append(summary.Errors, fmt.Sprintf("meeting %d (%s) sessions: %v", m.MeetingKey, m.MeetingName, err))
|
|
continue
|
|
}
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
mkVal := int(m.MeetingKey)
|
|
inserted, err := s.storeRaw(sessionFetch, &mkVal, nil)
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
}
|
|
s.delay()
|
|
|
|
for _, sess := range sessions {
|
|
if s.opts.DryRun {
|
|
summary.Sessions++
|
|
totalSessionsCount++
|
|
continue
|
|
}
|
|
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.Sessions++
|
|
totalSessionsCount++
|
|
}
|
|
|
|
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)
|
|
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,
|
|
))
|
|
}
|
|
if err == nil && sessSummary.Status == "partial" {
|
|
partialSessions++
|
|
for _, partialErr := range sessSummary.Errors {
|
|
summary.Errors = append(summary.Errors, fmt.Sprintf(
|
|
"session %d (%s): %s", sess.SessionKey, sess.SessionName, partialErr,
|
|
))
|
|
}
|
|
}
|
|
summary.SessionSummaries = append(summary.SessionSummaries, ss)
|
|
summary.mergeCounts(sessSummary)
|
|
}
|
|
}
|
|
|
|
summary.Status = meetingStatus(sessionFailures, partialSessions, totalSessionsCount, s.opts.DryRun)
|
|
s.finishRun(runID, summary)
|
|
s.opts.Progress.Summary(summary)
|
|
return summary, nil
|
|
}
|
|
|
|
// IngestMeeting fetches meeting metadata, all sessions, and Race Hub datasets for each session.
|
|
func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
|
|
summary := Summary{
|
|
ScopeType: "meeting",
|
|
ScopeKey: fmt.Sprintf("%d", meetingKey),
|
|
DryRun: s.opts.DryRun,
|
|
}
|
|
|
|
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
|
|
if err != nil {
|
|
return summary, err
|
|
}
|
|
|
|
s.opts.Progress.Step("fetching meeting %d", meetingKey)
|
|
meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
|
|
return s.source.FetchMeetingsForMeetingKey(meetingKey)
|
|
})
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
mk := meetingKey
|
|
inserted, err := s.storeRaw(meetingFetch, &mk, nil)
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
}
|
|
s.delay()
|
|
|
|
for _, m := range meetings {
|
|
if s.opts.DryRun {
|
|
summary.Meetings++
|
|
continue
|
|
}
|
|
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.Meetings++
|
|
}
|
|
|
|
s.opts.Progress.Step("fetching sessions for meeting %d", meetingKey)
|
|
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
|
|
return s.source.FetchSessionsForMeeting(meetingKey)
|
|
})
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
mk := meetingKey
|
|
inserted, err := s.storeRaw(sessionFetch, &mk, nil)
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
}
|
|
s.delay()
|
|
|
|
for _, sess := range sessions {
|
|
if s.opts.DryRun {
|
|
summary.Sessions++
|
|
continue
|
|
}
|
|
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.Sessions++
|
|
}
|
|
|
|
sessionFailures := 0
|
|
partialSessions := 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)
|
|
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,
|
|
))
|
|
}
|
|
if err == nil && sessSummary.Status == "partial" {
|
|
partialSessions++
|
|
for _, partialErr := range sessSummary.Errors {
|
|
summary.Errors = append(summary.Errors, fmt.Sprintf(
|
|
"session %d (%s): %s", sess.SessionKey, sess.SessionName, partialErr,
|
|
))
|
|
}
|
|
}
|
|
summary.SessionSummaries = append(summary.SessionSummaries, ss)
|
|
summary.mergeCounts(sessSummary)
|
|
}
|
|
|
|
summary.Status = meetingStatus(sessionFailures, partialSessions, len(sessions), s.opts.DryRun)
|
|
s.finishRun(runID, 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
|
|
}
|
|
|
|
// IngestSession ingests Race Hub v1 datasets for a single session.
|
|
func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
|
summary := Summary{
|
|
ScopeType: "session",
|
|
ScopeKey: fmt.Sprintf("%d", sessionKey),
|
|
DryRun: s.opts.DryRun,
|
|
}
|
|
|
|
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
|
|
if err != nil {
|
|
return summary, err
|
|
}
|
|
|
|
s.opts.Progress.Step("fetching session %d", sessionKey)
|
|
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
|
|
return s.source.FetchSessionsForSessionKey(sessionKey)
|
|
})
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
if len(sessions) == 0 {
|
|
err := fmt.Errorf("session %d not found", sessionKey)
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
sess := sessions[0]
|
|
meetingKey := sess.MeetingKey
|
|
sk := sessionKey
|
|
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
inserted, err := s.storeRaw(sessionFetch, &meetingKey, &sk)
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
}
|
|
s.delay()
|
|
|
|
s.opts.Progress.Step("fetching meeting %d for session context", meetingKey)
|
|
meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
|
|
return s.source.FetchMeetingsForMeetingKey(meetingKey)
|
|
})
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
inserted, err := s.storeRaw(meetingFetch, &meetingKey, &sk)
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
for _, m := range meetings {
|
|
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.Meetings++
|
|
}
|
|
} else {
|
|
summary.Meetings = len(meetings)
|
|
}
|
|
s.delay()
|
|
|
|
if s.opts.DryRun {
|
|
summary.Sessions++
|
|
} else {
|
|
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
summary.Sessions++
|
|
}
|
|
|
|
datasetSummary, err := s.ingestSessionDatasets(sess)
|
|
summary.mergeCounts(datasetSummary)
|
|
summary.Errors = append(summary.Errors, datasetSummary.Errors...)
|
|
if err != nil {
|
|
return s.finishFailed(runID, summary, err)
|
|
}
|
|
|
|
summary.Status = datasetSummary.Status
|
|
s.finishRun(runID, summary)
|
|
s.opts.Progress.Summary(summary)
|
|
return summary, nil
|
|
}
|
|
|
|
func (s *Service) ingestSessionDatasets(sess models.Session) (Summary, error) {
|
|
sessionKey := sess.SessionKey
|
|
meetingKey := sess.MeetingKey
|
|
summary := Summary{
|
|
ScopeType: "session",
|
|
ScopeKey: fmt.Sprintf("%d", sessionKey),
|
|
DryRun: s.opts.DryRun,
|
|
}
|
|
sk := sessionKey
|
|
|
|
coverage, err := s.store.GetSessionCoverage(sessionKey)
|
|
if err != nil {
|
|
coverage = make(map[string]store.CoverageEntry)
|
|
}
|
|
|
|
if sess.IsCancelled {
|
|
s.opts.Progress.Step("session %d (%s) is cancelled; skipping Race Hub datasets", sessionKey, sess.SessionName)
|
|
if !s.opts.DryRun {
|
|
for _, dataset := range []string{
|
|
"drivers",
|
|
"session_result",
|
|
"starting_grid",
|
|
"stints",
|
|
"pit_stops",
|
|
"positions",
|
|
"race_control",
|
|
"weather",
|
|
"laps",
|
|
} {
|
|
_ = s.store.UpsertCoverage(sessionKey, dataset, "skipped", 0, "session cancelled")
|
|
}
|
|
}
|
|
summary.Status = statusForCancelled(s.opts.DryRun)
|
|
return summary, nil
|
|
}
|
|
|
|
// 1. Ingest drivers
|
|
if cov, ok := coverage["drivers"]; ok && cov.Status == "complete" && !s.opts.Force {
|
|
s.opts.Progress.Step("drivers already complete for session %d, skipping", sessionKey)
|
|
summary.Drivers = cov.RowCount
|
|
} else {
|
|
s.opts.Progress.Step("fetching drivers for session %d", sessionKey)
|
|
driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) {
|
|
return s.source.FetchDriversForSession(sessionKey)
|
|
})
|
|
if err != nil {
|
|
if !s.opts.DryRun {
|
|
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
|
|
}
|
|
return summary, err
|
|
}
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk)
|
|
if err != nil {
|
|
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
|
|
return summary, err
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
for _, d := range drivers {
|
|
if err := s.store.UpsertDriver(driverToStore(d)); err != nil {
|
|
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
|
|
return summary, err
|
|
}
|
|
if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil {
|
|
_ = s.store.UpsertCoverage(sessionKey, "drivers", "failed", 0, err.Error())
|
|
return summary, err
|
|
}
|
|
summary.Drivers++
|
|
}
|
|
_ = s.store.UpsertCoverage(sessionKey, "drivers", "complete", len(drivers), "")
|
|
} else {
|
|
summary.Drivers = len(drivers)
|
|
}
|
|
s.delay()
|
|
}
|
|
|
|
// 2. Ingest session_result
|
|
if cov, ok := coverage["session_result"]; ok && cov.Status == "complete" && !s.opts.Force {
|
|
s.opts.Progress.Step("session_result already complete for session %d, skipping", sessionKey)
|
|
summary.SessionResults = cov.RowCount
|
|
} else {
|
|
s.opts.Progress.Step("fetching session results for session %d", sessionKey)
|
|
resultFetch, results, err := fetchWithRetry(s, func() (FetchResult, []models.SessionResult, error) {
|
|
return s.source.FetchSessionResult(sessionKey)
|
|
})
|
|
if err != nil {
|
|
if !s.opts.DryRun {
|
|
_ = s.store.UpsertCoverage(sessionKey, "session_result", "failed", 0, err.Error())
|
|
}
|
|
return summary, err
|
|
}
|
|
summary.RawPayloads++
|
|
if !s.opts.DryRun {
|
|
inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk)
|
|
if err != nil {
|
|
_ = s.store.UpsertCoverage(sessionKey, "session_result", "failed", 0, err.Error())
|
|
return summary, err
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
for _, r := range results {
|
|
if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil {
|
|
_ = s.store.UpsertCoverage(sessionKey, "session_result", "failed", 0, err.Error())
|
|
return summary, err
|
|
}
|
|
summary.SessionResults++
|
|
}
|
|
_ = s.store.UpsertCoverage(sessionKey, "session_result", "complete", len(results), "")
|
|
} else {
|
|
summary.SessionResults = len(results)
|
|
}
|
|
s.delay()
|
|
}
|
|
|
|
// 3. Optional datasets
|
|
optionalIngests := []struct {
|
|
name string
|
|
run func(*Summary, int, int) error
|
|
}{
|
|
{name: "starting_grid", run: func(summary *Summary, meetingKey, sessionKey int) error {
|
|
return s.ingestStartingGrid(summary, sess)
|
|
}},
|
|
{name: "stints", run: s.ingestStints},
|
|
{name: "pit_stops", run: s.ingestPitStops},
|
|
{name: "positions", run: s.ingestPositions},
|
|
{name: "race_control", run: s.ingestRaceControl},
|
|
{name: "weather", run: s.ingestWeather},
|
|
{name: "laps", run: s.ingestLaps},
|
|
}
|
|
|
|
for _, optional := range optionalIngests {
|
|
if cov, ok := coverage[optional.name]; ok && cov.Status == "complete" && !s.opts.Force {
|
|
s.opts.Progress.Step("%s already complete for session %d, skipping", optional.name, sessionKey)
|
|
switch optional.name {
|
|
case "starting_grid":
|
|
summary.StartingGrid = cov.RowCount
|
|
case "stints":
|
|
summary.Stints = cov.RowCount
|
|
case "pit_stops":
|
|
summary.PitStops = cov.RowCount
|
|
case "positions":
|
|
summary.Positions = cov.RowCount
|
|
case "race_control":
|
|
summary.RaceControl = cov.RowCount
|
|
case "weather":
|
|
summary.Weather = cov.RowCount
|
|
case "laps":
|
|
summary.Laps = cov.RowCount
|
|
}
|
|
continue
|
|
}
|
|
|
|
var prevCount int
|
|
switch optional.name {
|
|
case "starting_grid":
|
|
prevCount = summary.StartingGrid
|
|
case "stints":
|
|
prevCount = summary.Stints
|
|
case "pit_stops":
|
|
prevCount = summary.PitStops
|
|
case "positions":
|
|
prevCount = summary.Positions
|
|
case "race_control":
|
|
prevCount = summary.RaceControl
|
|
case "weather":
|
|
prevCount = summary.Weather
|
|
case "laps":
|
|
prevCount = summary.Laps
|
|
}
|
|
|
|
err := optional.run(&summary, meetingKey, sk)
|
|
if err != nil {
|
|
summary.Errors = append(summary.Errors, fmt.Sprintf("%s: %v", optional.name, err))
|
|
if !s.opts.DryRun {
|
|
_ = s.store.UpsertCoverage(sessionKey, optional.name, "failed", 0, err.Error())
|
|
}
|
|
} else {
|
|
if !s.opts.DryRun {
|
|
var newCount int
|
|
switch optional.name {
|
|
case "starting_grid":
|
|
newCount = summary.StartingGrid - prevCount
|
|
case "stints":
|
|
newCount = summary.Stints - prevCount
|
|
case "pit_stops":
|
|
newCount = summary.PitStops - prevCount
|
|
case "positions":
|
|
newCount = summary.Positions - prevCount
|
|
case "race_control":
|
|
newCount = summary.RaceControl - prevCount
|
|
case "weather":
|
|
newCount = summary.Weather - prevCount
|
|
case "laps":
|
|
newCount = summary.Laps - prevCount
|
|
}
|
|
_ = s.store.UpsertCoverage(sessionKey, optional.name, "complete", newCount, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
summary.Status = statusForErrors(s.opts.DryRun, summary.Errors)
|
|
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, partialSessions, sessionTotal int, dryRun bool) string {
|
|
if dryRun {
|
|
return "dry_run"
|
|
}
|
|
if sessionFailures == 0 {
|
|
if partialSessions > 0 {
|
|
return "partial"
|
|
}
|
|
return "completed"
|
|
}
|
|
if sessionFailures == sessionTotal {
|
|
return "failed"
|
|
}
|
|
return "partial"
|
|
}
|
|
|
|
func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) {
|
|
if s.opts.DryRun {
|
|
return 0, nil
|
|
}
|
|
return s.store.CreateIngestionRun(scopeType, scopeKey, false)
|
|
}
|
|
|
|
func (s *Service) finishRun(runID int64, summary Summary) {
|
|
if s.opts.DryRun || runID == 0 {
|
|
return
|
|
}
|
|
b, _ := json.Marshal(summary)
|
|
_ = s.store.FinishIngestionRun(runID, summary.Status, string(b))
|
|
}
|
|
|
|
func (s *Service) finishFailed(runID int64, summary Summary, err error) (Summary, error) {
|
|
summary.Status = "failed"
|
|
summary.Errors = append(summary.Errors, err.Error())
|
|
if runID != 0 && !s.opts.DryRun {
|
|
b, _ := json.Marshal(summary)
|
|
_ = s.store.FinishIngestionRun(runID, summary.Status, string(b))
|
|
}
|
|
s.opts.Progress.Summary(summary)
|
|
return summary, err
|
|
}
|
|
|
|
func (s *Service) storeRaw(fetch FetchResult, meetingKey, sessionKey *int) (bool, error) {
|
|
_, inserted, err := s.store.InsertRawPayload(store.RawPayload{
|
|
Source: sourceOpenF1,
|
|
Endpoint: fetch.Endpoint,
|
|
RequestKey: fetch.RequestKey,
|
|
MeetingKey: meetingKey,
|
|
SessionKey: sessionKey,
|
|
Payload: string(fetch.Body),
|
|
FetchedAt: fetch.FetchedAt,
|
|
ProvenanceJSON: provenanceJSON(fetch),
|
|
})
|
|
return inserted, err
|
|
}
|
|
|
|
func (s *Service) delay() {
|
|
if s.opts.RequestDelay > 0 {
|
|
time.Sleep(s.opts.RequestDelay)
|
|
}
|
|
}
|
|
|
|
func (s *Service) ingestStartingGrid(summary *Summary, sess models.Session) error {
|
|
sessionKey := sess.SessionKey
|
|
meetingKey := sess.MeetingKey
|
|
sourceSessionKey, err := s.startingGridSourceSessionKey(sess)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if sourceSessionKey == sessionKey {
|
|
s.opts.Progress.Step("fetching starting grid for session %d", sessionKey)
|
|
} else {
|
|
s.opts.Progress.Step("fetching starting grid for session %d from qualifying session %d", sessionKey, sourceSessionKey)
|
|
}
|
|
fetch, grid, err := fetchWithRetry(s, func() (FetchResult, []models.StartingGrid, error) {
|
|
return s.source.FetchStartingGrid(sourceSessionKey)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sourceSessionKey != sessionKey {
|
|
fetch.RequestKey = fmt.Sprintf("%s;target_session_key=%d", fetch.RequestKey, sessionKey)
|
|
}
|
|
|
|
mk := meetingKey
|
|
sk := sessionKey
|
|
summary.RawPayloads++
|
|
if s.opts.DryRun {
|
|
summary.StartingGrid = len(grid)
|
|
s.delay()
|
|
return nil
|
|
}
|
|
|
|
inserted, err := s.storeRaw(fetch, &mk, &sk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
for _, g := range grid {
|
|
g.SessionKey = sessionKey
|
|
g.MeetingKey = meetingKey
|
|
if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil {
|
|
return err
|
|
}
|
|
summary.StartingGrid++
|
|
}
|
|
s.delay()
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) startingGridSourceSessionKey(sess models.Session) (int, error) {
|
|
if !isRaceSession(sess) {
|
|
return sess.SessionKey, nil
|
|
}
|
|
|
|
_, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
|
|
return s.source.FetchSessionsForMeeting(sess.MeetingKey)
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
s.delay()
|
|
|
|
for _, candidate := range sessions {
|
|
if isQualifyingSession(candidate) {
|
|
return candidate.SessionKey, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("no qualifying session found for meeting %d", sess.MeetingKey)
|
|
}
|
|
|
|
func isRaceSession(sess models.Session) bool {
|
|
return strings.EqualFold(sess.SessionType, "Race") || strings.EqualFold(sess.SessionName, "Race")
|
|
}
|
|
|
|
func isQualifyingSession(sess models.Session) bool {
|
|
return strings.EqualFold(sess.SessionType, "Qualifying") || strings.EqualFold(sess.SessionName, "Qualifying")
|
|
}
|
|
|
|
func (s *Service) ingestStints(summary *Summary, meetingKey, sessionKey int) error {
|
|
s.opts.Progress.Step("fetching stints for session %d", sessionKey)
|
|
fetch, stints, err := fetchWithRetry(s, func() (FetchResult, []models.Stint, error) {
|
|
return s.source.FetchStintsForSession(sessionKey)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
|
for _, st := range stints {
|
|
if err := s.store.UpsertStint(stintToStore(st)); err != nil {
|
|
return err
|
|
}
|
|
summary.Stints++
|
|
}
|
|
return nil
|
|
}, func() { summary.Stints = len(stints) })
|
|
}
|
|
|
|
func (s *Service) ingestPitStops(summary *Summary, meetingKey, sessionKey int) error {
|
|
s.opts.Progress.Step("fetching pit stops for session %d", sessionKey)
|
|
fetch, pits, err := fetchWithRetry(s, func() (FetchResult, []models.Pit, error) {
|
|
return s.source.FetchPitStopsForSession(sessionKey)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
|
for _, p := range pits {
|
|
if err := s.store.UpsertPitStop(pitStopToStore(p)); err != nil {
|
|
return err
|
|
}
|
|
summary.PitStops++
|
|
}
|
|
return nil
|
|
}, func() { summary.PitStops = len(pits) })
|
|
}
|
|
|
|
func (s *Service) ingestPositions(summary *Summary, meetingKey, sessionKey int) error {
|
|
s.opts.Progress.Step("fetching positions for session %d", sessionKey)
|
|
fetch, positions, err := fetchWithRetry(s, func() (FetchResult, []models.Position, error) {
|
|
return s.source.FetchPositionsForSession(sessionKey)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
|
for _, p := range positions {
|
|
if err := s.store.UpsertPositionSample(positionToStore(p)); err != nil {
|
|
return err
|
|
}
|
|
summary.Positions++
|
|
}
|
|
return nil
|
|
}, func() { summary.Positions = len(positions) })
|
|
}
|
|
|
|
func (s *Service) ingestRaceControl(summary *Summary, meetingKey, sessionKey int) error {
|
|
s.opts.Progress.Step("fetching race control for session %d", sessionKey)
|
|
fetch, messages, err := fetchWithRetry(s, func() (FetchResult, []models.RaceControl, error) {
|
|
return s.source.FetchRaceControlForSession(sessionKey)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
|
for _, rc := range messages {
|
|
if err := s.store.UpsertRaceControlMessage(raceControlToStore(rc)); err != nil {
|
|
return err
|
|
}
|
|
summary.RaceControl++
|
|
}
|
|
return nil
|
|
}, func() { summary.RaceControl = len(messages) })
|
|
}
|
|
|
|
func (s *Service) ingestWeather(summary *Summary, meetingKey, sessionKey int) error {
|
|
s.opts.Progress.Step("fetching weather for session %d", sessionKey)
|
|
fetch, samples, err := fetchWithRetry(s, func() (FetchResult, []models.Weather, error) {
|
|
return s.source.FetchWeatherForSession(sessionKey)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
|
for _, w := range samples {
|
|
if err := s.store.UpsertWeatherSample(weatherToStore(w)); err != nil {
|
|
return err
|
|
}
|
|
summary.Weather++
|
|
}
|
|
return nil
|
|
}, func() { summary.Weather = len(samples) })
|
|
}
|
|
|
|
func (s *Service) ingestLaps(summary *Summary, meetingKey, sessionKey int) error {
|
|
s.opts.Progress.Step("fetching laps for session %d", sessionKey)
|
|
fetch, laps, err := fetchWithRetry(s, func() (FetchResult, []models.Lap, error) {
|
|
return s.source.FetchLapsForSession(sessionKey)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
|
for _, l := range laps {
|
|
if err := s.store.UpsertLap(lapToStore(l)); err != nil {
|
|
return err
|
|
}
|
|
summary.Laps++
|
|
}
|
|
return nil
|
|
}, func() { summary.Laps = len(laps) })
|
|
}
|
|
|
|
func (s *Service) storeAnalyticsFetch(
|
|
summary *Summary,
|
|
fetch FetchResult,
|
|
meetingKey, sessionKey int,
|
|
storeRows func() error,
|
|
setDryRunCount func(),
|
|
) error {
|
|
mk := meetingKey
|
|
sk := sessionKey
|
|
summary.RawPayloads++
|
|
if s.opts.DryRun {
|
|
setDryRunCount()
|
|
s.delay()
|
|
return nil
|
|
}
|
|
|
|
inserted, err := s.storeRaw(fetch, &mk, &sk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inserted {
|
|
summary.RawInserted++
|
|
}
|
|
if err := storeRows(); err != nil {
|
|
return err
|
|
}
|
|
s.delay()
|
|
return nil
|
|
}
|
|
|
|
func statusForDryRun(dryRun bool) string {
|
|
if dryRun {
|
|
return "dry_run"
|
|
}
|
|
return "completed"
|
|
}
|
|
|
|
func statusForErrors(dryRun bool, errs []string) string {
|
|
if dryRun {
|
|
return "dry_run"
|
|
}
|
|
if len(errs) > 0 {
|
|
return "partial"
|
|
}
|
|
return "completed"
|
|
}
|
|
|
|
func statusForCancelled(dryRun bool) string {
|
|
if dryRun {
|
|
return "dry_run"
|
|
}
|
|
return "cancelled"
|
|
}
|
|
|
|
type fetchFunc[T any] func() (FetchResult, T, error)
|
|
|
|
func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) {
|
|
var zero T
|
|
var lastErr error
|
|
|
|
for attempt := 0; attempt < s.opts.MaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
backoff := s.opts.RetryBackoff * time.Duration(1<<attempt)
|
|
jitter := time.Duration(rand.Int63n(int64(backoff / 4)))
|
|
wait := backoff + jitter
|
|
|
|
if lastErr != nil {
|
|
var rle *api.RateLimitError
|
|
if errors.As(lastErr, &rle) && rle.RetryAfter > wait {
|
|
wait = rle.RetryAfter
|
|
}
|
|
}
|
|
time.Sleep(wait)
|
|
}
|
|
|
|
fetch, data, err := fn()
|
|
if err == nil {
|
|
return fetch, data, nil
|
|
}
|
|
lastErr = err
|
|
|
|
if api.IsLiveSessionError(err) {
|
|
return FetchResult{}, zero, err
|
|
}
|
|
if !isRetryable(err) {
|
|
return FetchResult{}, zero, err
|
|
}
|
|
}
|
|
|
|
return FetchResult{}, zero, lastErr
|
|
}
|
|
|
|
func isRetryable(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var rle *api.RateLimitError
|
|
if errors.As(err, &rle) {
|
|
return true
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
if strings.Contains(msg, "status 429") ||
|
|
strings.Contains(msg, "status 5") ||
|
|
strings.Contains(msg, "timeout") ||
|
|
strings.Contains(msg, "connection reset") ||
|
|
strings.Contains(msg, "temporary") ||
|
|
strings.Contains(msg, "rate limit") {
|
|
return true
|
|
}
|
|
var netErr interface{ Timeout() bool }
|
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|