From e44bba3774e2eaa6c904d182cd13f06c226e7ccd Mon Sep 17 00:00:00 2001 From: Maris Popens Date: Thu, 24 Sep 2026 14:53:55 +0300 Subject: [PATCH] feat: durable cache so finished sessions survive restarts OpenF1's free tier locks out all access (past sessions included) for a stretch around every live session. Finished sessions were already kept in memory so a lockout couldn't blank them, but a restart wiped that - a deploy landing during a lockout left the tyre and latest-session tiles empty until it lifted. - cache: getDurable/setDurable keep never-changing data in memory and, when CACHE_DIR is set, mirror it to disk as small JSON files (atomic write, ignored after 30 days). tyre_usage and latest_session use it for finished sessions; anything else immutable can too. - image: /data owned by the non-root user, CACHE_DIR=/data by default - mount a volume there to persist; without one it still works, it just doesn't outlive the container. - startup probes the directory and logs a warning if it isn't writable, instead of silently not persisting. - README, docker-compose example. Verified in the real scratch image: fresh named volume and no volume both start clean; a read-only mount logs the warning. --- Dockerfile | 7 ++++ README.md | 8 +++++ cache.go | 88 +++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yaml | 5 +++ latest.go | 4 +-- main.go | 14 +++++++- main_test.go | 30 +++++++++++++++- tyres.go | 4 +-- 8 files changed, 152 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 177c494..956c82c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 diff --git a/README.md b/README.md index 90d2239..0d5a78d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -59,9 +61,14 @@ 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 | @@ -69,6 +76,7 @@ services: | `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. diff --git a/cache.go b/cache.go index 8b034da..27dd2b8 100644 --- a/cache.go +++ b/cache.go @@ -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 @@ -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) { @@ -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()) +} diff --git a/docker-compose.yaml b/docker-compose.yaml index fa574df..227264c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/latest.go b/latest.go index 9625184..73532f0 100644 --- a/latest.go +++ b/latest.go @@ -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)) diff --git a/main.go b/main.go index 87b672d..4d08855 100644 --- a/main.go +++ b/main.go @@ -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 @@ -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) { diff --git a/main_test.go b/main_test.go index b60c94f..f8b7912 100644 --- a/main_test.go +++ b/main_test.go @@ -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)), } } @@ -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) + } +} diff --git a/tyres.go b/tyres.go index 8b98dc9..35c62a4 100644 --- a/tyres.go +++ b/tyres.go @@ -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 } @@ -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}