From 7b97f010c9832ef7d053e98d6c6b88a3ec4e1fbb Mon Sep 17 00:00:00 2001 From: matitos Date: Mon, 21 Sep 2026 00:16:43 +0000 Subject: [PATCH] Self-heal a stale cached bundle via a build version Phones kept running old JS because their cached index.html pointed at an old hashed asset. Both images now bake the commit SHA as BUILD_VERSION; the backend exposes it at GET /api/version and the client reloads once with a cache-busting query when its own baked version differs. Guarded by sessionStorage so it cannot loop. /api/version is unauthenticated like /api/health. --- .gitea/workflows/deploy.yml | 2 ++ backend/Dockerfile | 4 ++++ backend/app/api/routes.py | 7 +++++++ backend/app/main.py | 4 ++++ backend/tests/test_api.py | 7 +++++++ docker-compose.local.yml | 10 ++++++++-- docker-compose.yml | 10 ++++++++-- frontend/Dockerfile | 4 ++++ frontend/src/App.vue | 20 ++++++++++++++++++++ frontend/src/api.js | 1 + frontend/vite.config.js | 15 +++++++++++++++ 11 files changed, 80 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 93b8c34..6b461d4 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -133,6 +133,8 @@ jobs: # Persistent data root: repo var wins, else derive from the org # PERSISTENT_MOUNT (absolute HOST path), else the workspace fallback. CRYPTO_DATA_DIR: ${{ vars.CRYPTO_DATA_DIR || (vars.PERSISTENT_MOUNT && format('{0}/crypto_trading', vars.PERSISTENT_MOUNT)) || './data' }} + # Stamped into both images so the client can detect a stale cached bundle. + CRYPTO_BUILD_VERSION: ${{ github.sha }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} steps: diff --git a/backend/Dockerfile b/backend/Dockerfile index db97c8c..df9d304 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app +# Build stamp exposed via /api/version so the frontend can detect a stale cache. +ARG BUILD_VERSION=unknown +ENV BUILD_VERSION=${BUILD_VERSION} + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 66dfec7..221c66e 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -63,6 +63,13 @@ def health() -> dict[str, str]: return {"status": "ok"} +@router.get("/version") +def version(request: Request) -> dict[str, str]: + """Build version of the running backend, used by the client to self-heal + from a stale cached bundle after a deploy.""" + return {"version": getattr(request.app.state, "build_version", "unknown")} + + @router.get("/strategies", response_model=list[StrategyOut]) def strategies() -> list[StrategyOut]: return [strategy_spec(cls) for cls in list_strategies()] diff --git a/backend/app/main.py b/backend/app/main.py index 3b9a19a..774569c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -27,6 +28,9 @@ def create_app( await runtime.shutdown() app = FastAPI(title=settings.app_name, lifespan=lifespan) + # Baked at build time (see Dockerfile); the frontend compares its own + # version against this to self-heal from a stale cache. + app.state.build_version = os.environ.get("BUILD_VERSION", "unknown") app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origin_list, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 75ff94a..c3cd653 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -9,6 +9,13 @@ from app.market.fake import FakeMarketData from app.runtime import Runtime +def test_version_endpoint(runtime: Runtime) -> None: + app = create_app(runtime.settings, runtime) + with TestClient(app) as client: + assert client.get("/api/version").status_code == 200 + assert "version" in client.get("/api/version").json() + + def test_health_and_strategies(runtime: Runtime) -> None: app = create_app(runtime.settings, runtime) with TestClient(app) as client: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 5aa41b2..42a2c8c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -5,7 +5,10 @@ name: crypto_trading_local services: backend: - build: ./backend + build: + context: ./backend + args: + BUILD_VERSION: ${BUILD_VERSION:-unknown} container_name: crypto-trading-backend-local restart: unless-stopped environment: @@ -37,7 +40,10 @@ services: - "8000:8000" frontend: - build: ./frontend + build: + context: ./frontend + args: + BUILD_VERSION: ${BUILD_VERSION:-unknown} container_name: crypto-trader-frontend-local restart: unless-stopped depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index ffb1cc8..c62f0e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,10 @@ name: crypto_trading services: backend: - build: ./backend + build: + context: ./backend + args: + BUILD_VERSION: ${CRYPTO_BUILD_VERSION:-unknown} image: crypto_trading-backend container_name: crypto-trading-backend restart: unless-stopped @@ -56,7 +59,10 @@ services: - internal frontend: - build: ./frontend + build: + context: ./frontend + args: + BUILD_VERSION: ${CRYPTO_BUILD_VERSION:-unknown} image: crypto_trading-frontend container_name: crypto-trading-frontend restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 59e26cf..f9633d1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,9 @@ FROM node:22-alpine AS build +# Same value as the backend's BUILD_VERSION so the client can compare them. +ARG BUILD_VERSION=unknown +ENV VITE_BUILD_VERSION=${BUILD_VERSION} + WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --no-audit --no-fund diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 49ba96f..bd0b566 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -133,7 +133,27 @@ function handleMessage(message) { } } +// If the server is running a newer build than this (possibly cached) bundle, +// reload once with a cache-busting query so phones pick up deploys without a +// manual hard-refresh. Guarded so it can never loop. +async function ensureFreshBuild() { + try { + const { version } = await api.getVersion() + if (!version || version === 'unknown') return + if (version === __BUILD_VERSION__) return + const key = `reloaded:${version}` + if (sessionStorage.getItem(key)) return + sessionStorage.setItem(key, '1') + const url = new URL(window.location.href) + url.searchParams.set('v', version) + window.location.replace(url.toString()) + } catch { + // ignore; not worth blocking the app + } +} + onMounted(async () => { + ensureFreshBuild() await Promise.all([ loadStrategies(), loadPresets(), diff --git a/frontend/src/api.js b/frontend/src/api.js index 368d47b..e20c073 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2,6 +2,7 @@ import axios from 'axios' const http = axios.create({ baseURL: '/api', timeout: 10000 }) +export const getVersion = () => http.get('/version').then((r) => r.data) export const getStatus = () => http.get('/status').then((r) => r.data) export const getStrategies = () => http.get('/strategies').then((r) => r.data) export const getPositions = () => http.get('/positions').then((r) => r.data) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c7bc4a4..44c5700 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,22 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import { execSync } from 'node:child_process' + +// Short git SHA (or a timestamp fallback) baked into the bundle so the client +// can detect when the server is running a newer build and reload itself. +let buildVersion = process.env.VITE_BUILD_VERSION || String(Date.now()) +if (!process.env.VITE_BUILD_VERSION) { + try { + buildVersion = execSync('git rev-parse --short HEAD').toString().trim() + } catch { + // not a git checkout; keep the timestamp + } +} export default defineConfig({ + define: { + __BUILD_VERSION__: JSON.stringify(buildVersion), + }, plugins: [vue()], server: { port: 5173,