Upgrades to Live and API Caching

This commit is contained in:
2026-03-27 00:30:53 -04:00
parent 7c2fcfef8d
commit 360bde4602
19 changed files with 838 additions and 225 deletions

View File

@@ -1,13 +1,14 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"database/sql"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
_ "modernc.org/sqlite"
)
// CacheStats tracks cache hit/miss statistics.
@@ -16,54 +17,76 @@ type CacheStats struct {
Misses int64
}
// FileCache implements a file-backed HTTP response cache with TTL expiry.
type FileCache struct {
dir string
// Cache implements a SQLite-backed HTTP response cache with TTL expiry.
// The database is a single file stored in the user's cache directory.
type Cache struct {
db *sql.DB
stats CacheStats
}
// Default TTL values.
const (
// CacheTTLShort is for current-season, frequently changing data (meetings, sessions, results).
// CacheTTLShort is for live/telemetry data that changes every few seconds.
CacheTTLShort = 15 * time.Minute
// CacheTTLMedium is for semi-stable data (championship standings, driver lists).
CacheTTLMedium = 1 * time.Hour
// CacheTTLLong is for historical data that rarely changes (past season data).
CacheTTLLong = 24 * time.Hour
// CacheTTLForever is for data that will never change (completed past-season results).
CacheTTLForever = 0
)
func NewFileCache() *FileCache {
var cacheDir string
userCacheDir, err := os.UserCacheDir()
if err == nil {
cacheDir = filepath.Join(userCacheDir, "box-box", "openf1")
} else {
// Fallback to a local .cache directory
cacheDir = ".cache/box-box/openf1"
// NewCache creates a SQLite-backed cache. The database file is placed in the
// user's OS cache directory under box-box/cache.db. No setup is required — the
// schema is created automatically on first run.
func NewCache() *Cache {
dbPath := cacheDBPath()
// Ensure the parent directory exists.
_ = os.MkdirAll(filepath.Dir(dbPath), 0755)
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
// Fall back to in-memory if the file can't be opened.
db, _ = sql.Open("sqlite", ":memory:")
}
// Ensure the cache directory exists
_ = os.MkdirAll(cacheDir, 0755)
// Limit connections — SQLite is single-writer.
db.SetMaxOpenConns(1)
return &FileCache{
dir: cacheDir,
}
// Create the table if it doesn't exist.
_, _ = db.Exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
)
`)
// Create an index on created_at for efficient expiry cleanup.
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_cache_created_at ON cache(created_at)`)
return &Cache{db: db}
}
func (c *FileCache) getCachePath(key string) string {
hash := sha256.Sum256([]byte(key))
filename := hex.EncodeToString(hash[:]) + ".json"
return filepath.Join(c.dir, filename)
// cacheDBPath returns the path to the cache database file.
func cacheDBPath() string {
userCacheDir, err := os.UserCacheDir()
if err == nil {
return filepath.Join(userCacheDir, "box-box", "cache.db")
}
return filepath.Join(".cache", "box-box", "cache.db")
}
// 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 {
// Historical data (specific year queries for past years)
// Historical data — completed past seasons never change.
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
return CacheTTLLong
return CacheTTLForever
}
// Frequently changing endpoints
// Live telemetry endpoints — change every few seconds during a session.
if strings.Contains(url, "/position") ||
strings.Contains(url, "/intervals") ||
strings.Contains(url, "/car_data") ||
@@ -71,88 +94,127 @@ func ttlForURL(url string) time.Duration {
return CacheTTLShort
}
// Semi-stable data
// Semi-stable data — standings and driver info.
if strings.Contains(url, "/championship") ||
strings.Contains(url, "/drivers") {
return CacheTTLMedium
}
// Default: medium TTL for everything else
// Default: medium TTL for everything else.
return CacheTTLMedium
}
// Get retrieves data from the cache. Returns nil, false if not found or expired.
func (c *FileCache) Get(key string) ([]byte, bool) {
path := c.getCachePath(key)
info, err := os.Stat(path)
func (c *Cache) Get(key string) ([]byte, bool) {
var data []byte
var createdAt int64
err := c.db.QueryRow(
`SELECT data, created_at FROM cache WHERE key = ?`, key,
).Scan(&data, &createdAt)
if err != nil {
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
}
// Check TTL based on file modification time
// Check TTL (0 = never expires).
ttl := ttlForURL(key)
if time.Since(info.ModTime()) > ttl {
// Expired — remove the stale file
_ = os.Remove(path)
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
}
data, err := os.ReadFile(path)
if err != nil {
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
if ttl > 0 {
age := time.Since(time.Unix(createdAt, 0))
if age > ttl {
// Expired — delete and return miss.
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
}
}
atomic.AddInt64(&c.stats.Hits, 1)
return data, true
}
// Set saves data to the cache.
func (c *FileCache) Set(key string, data []byte) error {
path := c.getCachePath(key)
return os.WriteFile(path, data, 0644)
// Set stores data in the cache, replacing any existing entry for the same key.
func (c *Cache) Set(key string, data []byte) error {
_, err := c.db.Exec(
`INSERT OR REPLACE INTO cache (key, data, created_at) VALUES (?, ?, ?)`,
key, data, time.Now().Unix(),
)
return err
}
// Stats returns current cache hit/miss stats.
func (c *FileCache) Stats() CacheStats {
func (c *Cache) Stats() CacheStats {
return CacheStats{
Hits: atomic.LoadInt64(&c.stats.Hits),
Misses: atomic.LoadInt64(&c.stats.Misses),
}
}
// Clear removes all cached files.
func (c *FileCache) Clear() error {
entries, err := os.ReadDir(c.dir)
if err != nil {
return err
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") {
_ = os.Remove(filepath.Join(c.dir, entry.Name()))
}
// Clear removes all cached entries.
func (c *Cache) Clear() error {
_, err := c.db.Exec(`DELETE FROM cache`)
return err
}
// Size returns the number of cached entries and total data size in bytes.
func (c *Cache) Size() (int, int64) {
var count int
var totalSize int64
_ = c.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(LENGTH(data)), 0) FROM cache`).Scan(&count, &totalSize)
return count, totalSize
}
// Prune removes expired entries from the cache. This can be called periodically
// to keep the database lean. It does not touch entries with CacheTTLForever.
func (c *Cache) Prune() error {
// Remove anything older than CacheTTLLong that isn't permanent.
// We can't perfectly distinguish by URL here, so we prune entries older
// than the longest non-permanent TTL. Permanent entries are re-set on each
// access, so their created_at stays fresh. As a safe cutoff, prune anything
// older than 7 days that hasn't been refreshed — this catches stale entries
// while keeping truly permanent historical data (which gets re-stored on use).
cutoff := time.Now().Add(-7 * 24 * time.Hour).Unix()
_, err := c.db.Exec(`DELETE FROM cache WHERE created_at < ?`, cutoff)
return err
}
// Close closes the database connection.
func (c *Cache) Close() error {
if c.db != nil {
return c.db.Close()
}
return nil
}
// Size returns the number of cached files and total size in bytes.
func (c *FileCache) Size() (int, int64) {
entries, err := os.ReadDir(c.dir)
// CleanupOldFileCache removes the old file-based cache directory. Since file
// cache entries used SHA-256 hashed filenames (not reversible), we can't
// migrate them — just clean up. New fetches will repopulate the SQLite cache.
func CleanupOldFileCache() {
oldDir := oldFileCacheDir()
entries, err := os.ReadDir(oldDir)
if err != nil {
return 0, 0
return
}
count := 0
var totalSize int64
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") {
count++
info, err := entry.Info()
if err == nil {
totalSize += info.Size()
}
_ = os.Remove(filepath.Join(oldDir, entry.Name()))
}
}
return count, totalSize
// Remove the old directory if empty.
remaining, _ := os.ReadDir(oldDir)
if len(remaining) == 0 {
_ = os.Remove(oldDir)
}
}
func oldFileCacheDir() string {
userCacheDir, err := os.UserCacheDir()
if err == nil {
return filepath.Join(userCacheDir, "box-box", "openf1")
}
return filepath.Join(".cache", "box-box", "openf1")
}

View File

@@ -7,15 +7,27 @@ import (
type OpenF1Client struct {
url string
apiKey string
httpClient *http.Client
cache *FileCache
cache *Cache
}
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
return &OpenF1Client{
url: url,
httpClient: &http.Client{Timeout: timeout},
cache: NewFileCache(),
cache: NewCache(),
}
}
// NewOpenF1ClientWithKey creates a client that authenticates with a Bearer token.
// This allows access during live sessions (paid tier).
func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *OpenF1Client {
return &OpenF1Client{
url: url,
apiKey: apiKey,
httpClient: &http.Client{Timeout: timeout},
cache: NewCache(),
}
}
@@ -28,3 +40,8 @@ func (c *OpenF1Client) CacheStats() CacheStats {
func (c *OpenF1Client) CacheSize() (int, int64) {
return c.cache.Size()
}
// Close releases resources held by the client (closes the cache database).
func (c *OpenF1Client) Close() error {
return c.cache.Close()
}

View File

@@ -8,10 +8,23 @@ import (
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/models"
)
// ErrLiveSessionLocked is returned when the OpenF1 API blocks free-tier access
// during a live F1 session. All endpoints (including historical data) return 401
// from ~30 min before a session starts until ~30 min after it ends.
var ErrLiveSessionLocked = errors.New("live F1 session in progress — API access is restricted to authenticated users until the session ends")
// IsLiveSessionError reports whether err (or any error in its chain) is the
// live-session lockout error from the OpenF1 API.
func IsLiveSessionError(err error) bool {
return errors.Is(err, ErrLiveSessionLocked)
}
// get performs a GET request and returns the response body, or an error if the
// status code is not 200 OK. It checks a local file cache before making the request.
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
@@ -19,13 +32,35 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(cachedData)), nil
}
resp, err := c.httpClient.Get(url)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Try to parse the JSON error body for a better message.
if resp.StatusCode == http.StatusUnauthorized {
body, _ := io.ReadAll(resp.Body)
var apiErr struct {
Detail string `json:"detail"`
}
if json.Unmarshal(body, &apiErr) == nil && apiErr.Detail != "" {
detail := strings.ToLower(apiErr.Detail)
if strings.Contains(detail, "live") && strings.Contains(detail, "session") {
return nil, fmt.Errorf("%w", ErrLiveSessionLocked)
}
return nil, fmt.Errorf("openf1 API: %s", apiErr.Detail)
}
}
return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url)
}
@@ -130,8 +165,8 @@ func (c *OpenF1Client) GetTeamChampionship(sessionKey int) ([]models.Championshi
return result, nil
}
// getLatestRaceSessionKey returns the session_key of the most recent Race session
// by fetching sessions filtered to session_name=Race and returning the last one.
// getLatestRaceSessionKey returns the session_key of the most recent completed
// Race session across all years.
func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race", c.url))
if err != nil {
@@ -146,16 +181,31 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
if len(sessions) == 0 {
return 0, errors.New("no Race sessions found")
}
return sessions[len(sessions)-1].SessionKey, nil
// Walk backwards to find the most recent completed race.
now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- {
s := sessions[i]
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil && endTime.Before(now) {
return s.SessionKey, nil
}
} else if s.DateStart != "" {
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil
}
}
}
return 0, errors.New("no completed Race sessions found")
}
// getLatestRaceSessionKeyForYear returns the session_key of the most recent Race session
// for a specific year.
// getLatestRaceSessionKeyForYear returns the session_key of the most recent
// completed Race session for a specific year. It walks backwards through the
// year's races to find one whose date_end is in the past (i.e. has results).
func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) {
// The OpenF1 API doesn't support a direct year filter on sessions yet (verified by docs/common patterns)
// so we'll fetch meetings for that year first, then find the latest session.
// Actually, session endpoint does support year filter according to some versions of docs.
// Let's try year filter first as it's more efficient.
body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race&year=%d", c.url, year))
if err != nil {
return 0, err
@@ -169,7 +219,26 @@ func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) {
if len(sessions) == 0 {
return 0, fmt.Errorf("no Race sessions found for year %d", year)
}
return sessions[len(sessions)-1].SessionKey, nil
// Walk backwards to find the most recent completed race.
now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- {
s := sessions[i]
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil && endTime.Before(now) {
return s.SessionKey, nil
}
} else if s.DateStart != "" {
// Fallback: if no DateEnd, check DateStart + 3 hours as a rough estimate.
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil
}
}
}
return 0, fmt.Errorf("no completed Race sessions found for year %d", year)
}
// GetLatestDriverChampionship returns championship standings for the most recent