Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o paddock-api .
RUN mkdir /data

# scratch has no CA bundle and no /usr/share/zoneinfo of its own - the CA
# bundle is copied in below, and the binary embeds tzdata itself (see the
Expand All @@ -20,6 +21,12 @@ WORKDIR /app
COPY --from=builder /build/paddock-api /app/paddock-api
COPY static ./static

# Durable cache for data that never changes once it exists (finished sessions).
# Owned by the non-root user so a volume mounted here is writable; without a
# volume it just lives in the container's writable layer.
COPY --from=builder --chown=65532:65532 /data /data
ENV CACHE_DIR=/data

EXPOSE 4463
USER 65532:65532

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ It's a small Go service (Echo), and the interesting decisions are mostly about *

Schedules, standings, and race results come from the [Jolpica](https://github.com/jolpica/jolpica-f1) Ergast-compatible mirror; tyre stint data comes from [OpenF1](https://openf1.org/). Both are free, but F1's live-timing backend behind them has a real rate ceiling, and it's shared - burn through it chasing something that isn't there, and every other endpoint on the same network starves too. So the rule here is: only ever ask for a session that's actually happened, and never guess. An in-memory cache, keyed to when the underlying data can actually change (not a fixed TTL), keeps most requests from hitting upstream at all.

OpenF1's free tier is locked out entirely (past sessions included) for a stretch around every live session, so once a session has finished its results are treated as immutable and kept for good - in memory, and on disk under `CACHE_DIR` if a volume is mounted there, so a restart during a lockout doesn't blank them. Anything else that never changes once it exists can use the same store.

Track maps break that pattern entirely, on purpose. Tracing a circuit's outline from a car's GPS telemetry only works once a car has actually driven it - which is useless for a brand-new venue's debut weekend, and turned out to be the single most fragile part of this whole service. It's replaced now with [bacinger/f1-circuits](https://github.com/bacinger/f1-circuits), a maintained dataset of real circuit geometry that doesn't care whether a session has happened yet. No live API call, no rate limit, works for a track that's never hosted a race.

# Getting Started
Expand All @@ -59,16 +61,22 @@ services:
- TIMEZONE=America/Edmonton # Specify your timezone.
- TRACK_COLOUR=#e5d486 # Specify desired track map color
- EVENT_DETAIL=main # Optional. main tracks qualis and races (inc. sprints), race tracks races.
volumes:
- paddock-cache:/data # Optional. Keeps finished-session data across restarts.
ports:
- 4463:4463
restart: unless-stopped

volumes:
paddock-cache:
```

| Variable | Required | Description |
|---|---|---|
| `TIMEZONE` | Yes | IANA timezone name (e.g. `America/Edmonton`, `Europe/Tallinn`) - every timestamp the API returns is converted to this. |
| `TRACK_COLOUR` | Yes | Hex colour (e.g. `#e5d486`) for the track-map line. |
| `EVENT_DETAIL` | No, defaults to `main` | Which sessions `/f1/next_race/` counts down to: `main` (quali + races, skips practice), `race` (races only), or `detailed` (every session). |
| `CACHE_DIR` | No, defaults to `/data` in the image | Where finished-session data is kept so it survives restarts. Mount a volume there (as above); without one it still works, it just doesn't outlive the container. Runs as UID 65532, so a bind mount or Kubernetes volume needs to be writable by it. |

Once it's running, grab the widgets you want from [`widgets/`](./widgets/) and drop them into your Glance config - see that folder's own README for setup and what each one shows. See the [Glance docs](https://github.com/glanceapp/glance/blob/main/docs/configuration.md#including-other-config-files) for how config files like these get included.

Expand Down
88 changes: 86 additions & 2 deletions cache.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package main

import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"time"
)

const durableTTL = 30 * 24 * time.Hour

type cacheEntry struct {
data any
expiresAt time.Time
Expand All @@ -13,10 +20,11 @@ type cacheEntry struct {
type cacheStore struct {
mu sync.RWMutex
store map[string]cacheEntry
dir string
}

func newCache() *cacheStore {
return &cacheStore{store: make(map[string]cacheEntry)}
func newCache(dir string) *cacheStore {
return &cacheStore{store: make(map[string]cacheEntry), dir: dir}
}

func (c *cacheStore) get(key string, now time.Time) (any, bool) {
Expand All @@ -39,3 +47,79 @@ func (c *cacheStore) set(key string, value any, expiresAt time.Time) {
c.store[key] = cacheEntry{data: value, expiresAt: expiresAt}
c.mu.Unlock()
}

// getDurable/setDurable are for data that never changes once it exists (a
// finished session's results). Kept in memory like any entry and, when a
// directory is configured, mirrored to disk so it survives a restart - the
// upstream (OpenF1) can be unreachable for long stretches around sessions.
func (c *cacheStore) getDurable(key string, now time.Time) (any, bool) {
if value, ok := c.get(key, now); ok {
return value, true
}
if c.dir == "" {
return nil, false
}
path := c.path(key)
info, err := os.Stat(path)
if err != nil || now.Sub(info.ModTime()) > durableTTL {
return nil, false
}
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
var value any
if json.Unmarshal(data, &value) != nil {
return nil, false
}
c.set(key, value, now.Add(durableTTL))
return value, true
}

func (c *cacheStore) setDurable(key string, value any, now time.Time) error {
c.set(key, value, now.Add(durableTTL))
if c.dir == "" {
return nil
}
data, err := json.Marshal(value)
if err != nil {
return err
}
if err := os.MkdirAll(c.dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(c.dir, ".tmp-*")
if err != nil {
return err
}
_, writeErr := tmp.Write(data)
closeErr := tmp.Close()
if err := errors.Join(writeErr, closeErr); err != nil {
os.Remove(tmp.Name())
return err
}
return os.Rename(tmp.Name(), c.path(key))
}

func (c *cacheStore) path(key string) string {
safe := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
return r
}
return '_'
}, key)
return filepath.Join(c.dir, safe+".json")
}

// probe checks the cache directory can actually be written to.
func (c *cacheStore) probe() error {
if err := os.MkdirAll(c.dir, 0o755); err != nil {
return err
}
f, err := os.CreateTemp(c.dir, ".probe-*")
if err != nil {
return err
}
f.Close()
return os.Remove(f.Name())
}
5 changes: 5 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ services:
- TIMEZONE=America/Edmonton
- TRACK_COLOUR=#e5d486
- EVENT_DETAIL=main
volumes:
- paddock-cache:/data
ports:
- 4463:4463
restart: unless-stopped

volumes:
paddock-cache:
4 changes: 2 additions & 2 deletions latest.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,14 @@ func (a *app) latestSession(c echo.Context) error {
// A finished session's classification never changes, so it's kept for
// good - OpenF1's free tier locks out all access while any session is live.
cacheKey := fmt.Sprintf("session_results:%d:%d:%s", year, r.Round, key)
if cached, ok := a.cache.get(cacheKey, now); ok {
if cached, ok := a.cache.getDurable(cacheKey, now); ok {
result["results"], ttl = cached, 5*time.Minute
} else if rows, ended, fetchErr := a.fetchSessionResults(key, at, now); fetchErr != nil {
result["upstream_error"] = fetchErr.Error()
} else {
result["results"], ttl = rows, 5*time.Minute
if ended && len(rows) > 0 {
a.cache.set(cacheKey, rows, now.Add(30*24*time.Hour))
a.keep(cacheKey, rows, now)
}
}
a.cache.set("f1:latest_session", result, now.Add(ttl))
Expand Down
14 changes: 13 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ func (a *app) fetchOpenF1(url string, target any) error {
return a.fetchJSON(url, target)
}

// keep stores data that never changes once it exists (see setDurable).
func (a *app) keep(key string, value any, now time.Time) {
if err := a.cache.setDurable(key, value, now); err != nil {
a.log.Warn("could not persist cache entry", "key", key, "error", err)
}
}

func newServer(a *app) *echo.Echo {
e := echo.New()
e.HideBanner, e.HidePort = true, true
Expand Down Expand Up @@ -137,7 +144,12 @@ func main() {
logger.Error("invalid configuration", "error", err)
os.Exit(1)
}
a := &app{config: cfg, client: &http.Client{Timeout: 15 * time.Second}, cache: newCache(), now: time.Now, log: logger, openF1Gap: 400 * time.Millisecond}
a := &app{config: cfg, client: &http.Client{Timeout: 15 * time.Second}, cache: newCache(envOr("CACHE_DIR", "")), now: time.Now, log: logger, openF1Gap: 400 * time.Millisecond}
if a.cache.dir != "" {
if err := a.cache.probe(); err != nil {
logger.Warn("CACHE_DIR is not writable, finished-session data will not survive restarts", "dir", a.cache.dir, "error", err)
}
}
port := envOr("PORT", "4463")
logger.Info("server starting", "port", port)
if err := newServer(a).Start(":" + port); err != nil && !errors.Is(err, http.ErrServerClosed) {
Expand Down
30 changes: 29 additions & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func testApp(t *testing.T, mock *upstreamMock) *app {
staticDir := t.TempDir()
return &app{
config: config{timezone: time.FixedZone("Test", -7*60*60), timezoneID: "America/Edmonton", eventDetail: "main", trackColour: "#e5d486", ergastBase: mock.server.URL + "/ergast", openF1Base: mock.server.URL + "/openf1", geometryBase: mock.server.URL + "/geometry", staticMapDir: staticDir},
client: mock.server.Client(), cache: newCache(), now: func() time.Time { return time.Date(2026, 3, 6, 13, 0, 0, 0, time.UTC) }, log: slog.New(slog.NewTextHandler(io.Discard, nil)),
client: mock.server.Client(), cache: newCache(""), now: func() time.Time { return time.Date(2026, 3, 6, 13, 0, 0, 0, time.UTC) }, log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}

Expand Down Expand Up @@ -276,3 +276,31 @@ func TestTeamNames(t *testing.T) {
}
}
}

func TestFinishedSessionsSurviveARestartWhenCacheDirIsSet(t *testing.T) {
mock := newUpstreamMock(t)
dir := t.TempDir()
first := testApp(t, mock)
first.cache = newCache(dir)
request(t, newServer(first), "/f1/tyre_usage/")
request(t, newServer(first), "/f1/latest_session/")

mock.mu.Lock()
mock.locked = true
mock.mu.Unlock()

// A "restarted" instance: empty memory, same directory, upstream locked out.
restarted := testApp(t, mock)
restarted.cache = newCache(dir)
tyres := decode(t, request(t, newServer(restarted), "/f1/tyre_usage/"))
latest := decode(t, request(t, newServer(restarted), "/f1/latest_session/"))
if tyres["sessions"].(map[string]any)["fp1"] == nil || tyres["upstream_error"] != nil || len(latest["results"].([]any)) != 3 || latest["upstream_error"] != nil {
t.Fatalf("expected persisted data to survive: tyres=%#v latest=%#v", tyres, latest)
}

// Without a directory the same restart has nothing to show.
amnesiac := testApp(t, mock)
if got := decode(t, request(t, newServer(amnesiac), "/f1/tyre_usage/")); got["upstream_error"] == nil {
t.Fatalf("expected an upstream error without a cache dir: %#v", got)
}
}
4 changes: 2 additions & 2 deletions tyres.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (a *app) tyreUsage(c echo.Context) error {
// sessions) while any session is live, which would otherwise blank
// data we already had.
sessionCacheKey := fmt.Sprintf("tyre_session:%d:%d:%s", year, selected.Round, key)
if cached, ok := a.cache.get(sessionCacheKey, now); ok {
if cached, ok := a.cache.getDurable(sessionCacheKey, now); ok {
sessions[key] = cached
continue
}
Expand All @@ -90,7 +90,7 @@ func (a *app) tyreUsage(c echo.Context) error {
}
sessions[key] = usage
if end, parseErr := time.Parse(time.RFC3339, match.End); parseErr == nil && end.Before(now) {
a.cache.set(sessionCacheKey, usage, now.Add(30*24*time.Hour))
a.keep(sessionCacheKey, usage, now)
}
}
result := map[string]any{"season": year, "round": selected.Round, "raceName": selected.RaceName, "sessions": sessions}
Expand Down
Loading