From b0fd2520960a1cf317689dad3642a93734e44fe5 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 4 Jul 2026 01:06:19 -0400 Subject: [PATCH] feat(cli): add track outline cache warmer Spike: existing TUI prefetch stored outlines under time.Now().Year(), so the CLI uses a new explicit year-aware prefetch path and the TUI wrapper now derives the year from meetings when available. --- cmd/main.go | 46 ++++++++- internal/api/cache.go | 6 ++ internal/api/openf1.go | 80 ++++++++++++-- internal/api/track_outline_prefetch_test.go | 109 ++++++++++++++++++++ 4 files changed, 227 insertions(+), 14 deletions(-) create mode 100644 internal/api/track_outline_prefetch_test.go diff --git a/cmd/main.go b/cmd/main.go index 5e84f5d..107d50a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -27,6 +27,7 @@ func main() { 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") ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds") + prefetchTrackOutlines := flag.Int("prefetch-track-outlines", 0, "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)") dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows") force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed") coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year") @@ -76,11 +77,21 @@ func main() { if *ingestNews { ingestFlags++ } + if *prefetchTrackOutlines != 0 { + ingestFlags++ + } if ingestFlags > 0 { if ingestFlags > 1 { - fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, or --ingest-news may be set") + fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set") os.Exit(1) } + if *prefetchTrackOutlines != 0 { + if err := runTrackOutlinePrefetch(client, *prefetchTrackOutlines); err != nil { + fmt.Fprintf(os.Stderr, "box-box track outline prefetch error: %v\n", err) + os.Exit(1) + } + return + } if *ingestNews { if err := runNewsIngestion(*dryRun, *dbPath); err != nil { fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err) @@ -88,7 +99,7 @@ func main() { } return } - + yearVal := *ingestYear if *backfillSeason != 0 { yearVal = *backfillSeason @@ -174,6 +185,35 @@ func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, fo return err } +func runTrackOutlinePrefetch(client *api.OpenF1Client, year int) error { + log.SetOutput(os.Stderr) + + fmt.Fprintf(os.Stderr, "track outlines: warming HTTP cache %s for %d\n", api.DefaultCacheDBPath(), year) + + meetings, err := client.GetMeetingsForYear(year) + if err != nil { + return fmt.Errorf("fetch meetings for %d: %w", year, err) + } + + result := client.PrefetchTrackOutlinesForYear(year, meetings) + fmt.Printf( + "track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n", + result.Year, + result.CachedBefore, + result.UniqueCircuits, + result.CachedAfter, + result.UniqueCircuits, + result.Skipped, + result.Fetched, + result.Failed, + ) + + if result.CachedAfter == 0 { + return fmt.Errorf("cached zero track outlines for %d", year) + } + return nil +} + func runNewsIngestion(dryRun bool, dbPath string) error { log.SetOutput(os.Stderr) @@ -275,7 +315,7 @@ func runCoverageReport(year int, dbPath string) error { } fmt.Printf("\n--- Season %d Coverage Report ---\n\n", year) - fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n", + fmt.Printf("%-35s | %-5s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s | %-2s\n", "Meeting / Session (Key)", "ID", "DR", "SR", "SG", "ST", "PS", "PO", "RC", "WE", "LA") fmt.Println(strings.Repeat("-", 82)) diff --git a/internal/api/cache.go b/internal/api/cache.go index 81f63f6..7057e0e 100644 --- a/internal/api/cache.go +++ b/internal/api/cache.go @@ -94,6 +94,12 @@ func cacheDBPath() string { return filepath.Join(".cache", "box-box", "cache.db") } +// DefaultCacheDBPath returns the HTTP cache database path used by the OpenF1 +// client in both TUI and web modes. +func DefaultCacheDBPath() string { + return cacheDBPath() +} + // ttlForURL determines the appropriate TTL based on the URL pattern. // Returns 0 (CacheTTLForever) for historical data that will never change. func ttlForURL(url string) time.Duration { diff --git a/internal/api/openf1.go b/internal/api/openf1.go index c55ef79..d61874e 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -623,6 +623,19 @@ func (c *OpenF1Client) GetTeamRadio(sessionKey, driverNumber int) ([]models.Team // to maximise the chance of finding data quickly. var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24} +// TrackOutlinePrefetchResult summarizes a season track-outline cache warming +// run. Counts are scoped to the unique non-zero circuit keys in the provided +// meeting list. +type TrackOutlinePrefetchResult struct { + Year int + UniqueCircuits int + CachedBefore int + CachedAfter int + Skipped int + Fetched int + Failed int +} + // PrefetchTrackOutlines fetches GPS location data for every circuit in the // provided meeting list and stores it in the cache so the track map tab can // render during live sessions when the free-tier API is locked. @@ -632,28 +645,58 @@ var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24} // Errors per-circuit are silently ignored — this is a best-effort operation // and must never block or crash the main UI. func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) { + year := time.Now().Year() + for _, m := range meetings { + if m.Year != 0 { + year = m.Year + break + } + } + _ = c.PrefetchTrackOutlinesForYear(year, meetings) +} + +// PrefetchTrackOutlinesForYear fetches and caches track outlines for unique +// circuits in the provided meeting list, storing them under the explicit season +// year. Unlike PrefetchTrackOutlines, it returns accounting suitable for CLI +// cache-warming workflows. +func (c *OpenF1Client) PrefetchTrackOutlinesForYear(year int, meetings []models.Meeting) TrackOutlinePrefetchResult { const maxWorkers = 3 - year := time.Now().Year() - - // Filter to meetings that need fetching. - var pending []models.Meeting + result := TrackOutlinePrefetchResult{Year: year} + uniqueByCircuit := make(map[int]models.Meeting) + var unique []models.Meeting for _, m := range meetings { if m.CircuitKey == 0 { continue } + if _, exists := uniqueByCircuit[m.CircuitKey]; exists { + continue + } + uniqueByCircuit[m.CircuitKey] = m + unique = append(unique, m) + } + + result.UniqueCircuits = len(unique) + + // Filter to meetings that need fetching. + var pending []models.Meeting + for _, m := range unique { if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok { + result.CachedBefore++ + result.Skipped++ continue // already cached for this season } pending = append(pending, m) } if len(pending) == 0 { - return + result.CachedAfter = result.CachedBefore + return result } sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup + var mu sync.Mutex for _, mtg := range pending { mtg := mtg // capture @@ -662,19 +705,34 @@ func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) { go func() { defer wg.Done() defer func() { <-sem }() - c.prefetchCircuit(mtg, year) + ok := c.prefetchCircuit(mtg, year) + mu.Lock() + if ok { + result.Fetched++ + } else { + result.Failed++ + } + mu.Unlock() }() } wg.Wait() + + for _, m := range unique { + if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok { + result.CachedAfter++ + } + } + + return result } // prefetchCircuit fetches the track outline for a single meeting and stores it. // It prefers completed sessions (past date_end) so the data is full and stable. -func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) { +func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) bool { sessions, err := c.GetSessionsForMeeting(int(mtg.MeetingKey)) if err != nil || len(sessions) == 0 { - return + return false } // Pick the best session: prefer a completed race, then any session with @@ -696,7 +754,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) { } } if bestSession == nil { - return + return false } // Try candidate drivers in order until we find one with enough points. @@ -706,7 +764,7 @@ func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) { continue } // Store under the circuit key for this year and stop. - _ = c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) - return + return c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) == nil } + return false } diff --git a/internal/api/track_outline_prefetch_test.go b/internal/api/track_outline_prefetch_test.go new file mode 100644 index 0000000..95ea363 --- /dev/null +++ b/internal/api/track_outline_prefetch_test.go @@ -0,0 +1,109 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +func newTrackOutlineTestClient(t *testing.T, srvURL string) *OpenF1Client { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + c := NewOpenF1Client(srvURL, 5*time.Second) + c.pacer = &requestPacer{} + t.Cleanup(func() { _ = c.Close() }) + return c +} + +func TestPrefetchTrackOutlinesForYearSkipsCachedAndWritesLocations(t *testing.T) { + var sessionsByMeeting = map[string][]models.Session{ + "202": { + { + SessionKey: 9002, + SessionName: "Race", + MeetingKey: 202, + CircuitKey: 2, + DateEnd: "2026-01-01T12:00:00+00:00", + }, + }, + } + var sessionsRequested []string + var locationsRequested []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/sessions": + meetingKey := r.URL.Query().Get("meeting_key") + sessionsRequested = append(sessionsRequested, meetingKey) + _ = json.NewEncoder(w).Encode(sessionsByMeeting[meetingKey]) + case "/v1/location": + sessionKey := r.URL.Query().Get("session_key") + driverNumber := r.URL.Query().Get("driver_number") + locationsRequested = append(locationsRequested, sessionKey+"/"+driverNumber) + _ = json.NewEncoder(w).Encode(testLocations(9002, 1, 51)) + default: + t.Fatalf("unexpected request path %s", r.URL.Path) + } + })) + defer srv.Close() + + client := newTrackOutlineTestClient(t, srv.URL) + if err := client.Cache().SetTrackOutline(1, 2026, testLocations(9001, 1, 51)); err != nil { + t.Fatalf("SetTrackOutline() error = %v", err) + } + + result := client.PrefetchTrackOutlinesForYear(2026, []models.Meeting{ + {MeetingKey: 101, Year: 2026, Circuit: models.Circuit{CircuitKey: 1}}, + {MeetingKey: 202, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}}, + {MeetingKey: 303, Year: 2026, Circuit: models.Circuit{CircuitKey: 2}}, + }) + + if result.UniqueCircuits != 2 { + t.Fatalf("UniqueCircuits = %d, want 2", result.UniqueCircuits) + } + if result.CachedBefore != 1 || result.Skipped != 1 || result.Fetched != 1 || result.Failed != 0 || result.CachedAfter != 2 { + t.Fatalf("unexpected result: %+v", result) + } + if got, want := len(sessionsRequested), 1; got != want { + t.Fatalf("sessions requested %d time(s), want %d: %v", got, want, sessionsRequested) + } + if sessionsRequested[0] != "202" { + t.Fatalf("requested meeting %s, want 202", sessionsRequested[0]) + } + if got, want := len(locationsRequested), 1; got != want { + t.Fatalf("locations requested %d time(s), want %d: %v", got, want, locationsRequested) + } + if locationsRequested[0] != "9002/1" { + t.Fatalf("requested location %s, want 9002/1", locationsRequested[0]) + } + + locs, ok := client.Cache().GetTrackOutline(2, 2026) + if !ok { + t.Fatal("expected circuit 2 outline to be cached") + } + if len(locs) != 51 { + t.Fatalf("cached %d locations, want 51", len(locs)) + } +} + +func testLocations(sessionKey, driverNumber, count int) []models.Location { + locs := make([]models.Location, count) + for i := range locs { + locs[i] = models.Location{ + Date: "2026-01-01T12:00:" + strconv.Itoa(i%60) + "+00:00", + DriverNumber: driverNumber, + MeetingKey: 202, + SessionKey: sessionKey, + X: float64(i), + Y: float64(i * 2), + } + } + return locs +}