Files
box-box/internal/api/cache.go

221 lines
6.2 KiB
Go
Raw Normal View History

2026-03-03 01:16:04 -05:00
package api
import (
2026-03-27 00:30:53 -04:00
"database/sql"
2026-03-03 01:16:04 -05:00
"os"
"path/filepath"
2026-03-03 02:29:34 -05:00
"strings"
"sync/atomic"
"time"
2026-03-27 00:30:53 -04:00
_ "modernc.org/sqlite"
2026-03-03 01:16:04 -05:00
)
2026-03-03 02:29:34 -05:00
// CacheStats tracks cache hit/miss statistics.
type CacheStats struct {
Hits int64
Misses int64
}
2026-03-27 00:30:53 -04:00
// 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
2026-03-03 02:29:34 -05:00
stats CacheStats
2026-03-03 01:16:04 -05:00
}
2026-03-03 02:29:34 -05:00
// Default TTL values.
const (
2026-03-27 00:30:53 -04:00
// CacheTTLShort is for live/telemetry data that changes every few seconds.
2026-03-03 02:29:34 -05:00
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
2026-03-27 00:30:53 -04:00
// CacheTTLForever is for data that will never change (completed past-season results).
CacheTTLForever = 0
2026-03-03 02:29:34 -05:00
)
2026-03-27 00:30:53 -04:00
// 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()
2026-03-03 01:16:04 -05:00
2026-03-27 00:30:53 -04:00
// Ensure the parent directory exists.
_ = os.MkdirAll(filepath.Dir(dbPath), 0755)
2026-03-03 01:16:04 -05:00
2026-03-27 00:30:53 -04:00
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:")
2026-03-03 01:16:04 -05:00
}
2026-03-27 00:30:53 -04:00
// Limit connections — SQLite is single-writer.
db.SetMaxOpenConns(1)
// 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}
2026-03-03 01:16:04 -05:00
}
2026-03-27 00:30:53 -04:00
// 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")
2026-03-03 01:16:04 -05:00
}
2026-03-03 02:29:34 -05:00
// ttlForURL determines the appropriate TTL based on the URL pattern.
2026-03-27 00:30:53 -04:00
// Returns 0 (CacheTTLForever) for historical data that will never change.
2026-03-03 02:29:34 -05:00
func ttlForURL(url string) time.Duration {
2026-03-27 00:30:53 -04:00
// Historical data — completed past seasons never change.
2026-03-03 02:29:34 -05:00
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
2026-03-27 00:30:53 -04:00
return CacheTTLForever
2026-03-03 02:29:34 -05:00
}
2026-03-27 00:30:53 -04:00
// Live telemetry endpoints — change every few seconds during a session.
2026-03-03 02:29:34 -05:00
if strings.Contains(url, "/position") ||
strings.Contains(url, "/intervals") ||
strings.Contains(url, "/car_data") ||
strings.Contains(url, "/location") {
return CacheTTLShort
}
2026-03-27 00:30:53 -04:00
// Semi-stable data — standings and driver info.
2026-03-03 02:29:34 -05:00
if strings.Contains(url, "/championship") ||
strings.Contains(url, "/drivers") {
return CacheTTLMedium
}
2026-03-27 00:30:53 -04:00
// Default: medium TTL for everything else.
2026-03-03 02:29:34 -05:00
return CacheTTLMedium
}
// Get retrieves data from the cache. Returns nil, false if not found or expired.
2026-03-27 00:30:53 -04:00
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)
2026-03-03 02:29:34 -05:00
if err != nil {
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
}
2026-03-27 00:30:53 -04:00
// Check TTL (0 = never expires).
2026-03-03 02:29:34 -05:00
ttl := ttlForURL(key)
2026-03-27 00:30:53 -04:00
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
}
2026-03-03 01:16:04 -05:00
}
2026-03-03 02:29:34 -05:00
atomic.AddInt64(&c.stats.Hits, 1)
2026-03-03 01:16:04 -05:00
return data, true
}
2026-03-27 00:30:53 -04:00
// 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
2026-03-03 01:16:04 -05:00
}
2026-03-03 02:29:34 -05:00
// Stats returns current cache hit/miss stats.
2026-03-27 00:30:53 -04:00
func (c *Cache) Stats() CacheStats {
2026-03-03 02:29:34 -05:00
return CacheStats{
Hits: atomic.LoadInt64(&c.stats.Hits),
Misses: atomic.LoadInt64(&c.stats.Misses),
}
}
2026-03-27 00:30:53 -04:00
// 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()
2026-03-03 02:29:34 -05:00
}
return nil
}
2026-03-27 00:30:53 -04:00
// 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)
2026-03-03 02:29:34 -05:00
if err != nil {
2026-03-27 00:30:53 -04:00
return
2026-03-03 02:29:34 -05:00
}
2026-03-27 00:30:53 -04:00
2026-03-03 02:29:34 -05:00
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") {
2026-03-27 00:30:53 -04:00
_ = os.Remove(filepath.Join(oldDir, entry.Name()))
2026-03-03 02:29:34 -05:00
}
}
2026-03-27 00:30:53 -04:00
// 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")
2026-03-03 02:29:34 -05:00
}