Phase 33 (story: .agent/user_stories/cache-busting.md). - app/core/caching.py: asset_version() — git short SHA (a commit is a deploy), stable content-hash fallback for non-git checkouts, "dev" for a missing static dir; computed once per process. CachingMiddleware — the five HTML pages revalidate (no-cache) with ?v=<token> asset refs rewritten in flight; /assets/* is public, max-age=31536000, immutable; everything else (all /api/*, the SSE chat stream in particular) passes through byte-identical. - tests/e2e/test_cache_busting.py: fresh-Chromium wire assertions — document no-cache, versioned CSS/JS request URLs sharing one token, immutable asset headers, /api/health baseline headers, SSE chat to done (mock LLM). - README 'Caching / deploys' section + story file. Also fixed two prod-image defects surfaced by this phase's podman smoke (the full app would not boot): - Containerfile: ship the scripts/ package — app/api/sync.py (phase 32) imports scripts.git_sync / scripts.import_docs at module level, so the container crashed on boot (ModuleNotFoundError: No module named 'scripts'). - compose.yaml: pass BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET through to the app service (:- defaults keep 'podman compose up -d db' working; the app's own fail-loud gate still names missing admin auth). Smoke: podman compose --profile prod up -d on a fresh image + a fresh Chromium profile — /, /sources.html and /login.html all served Cache-Control: no-cache; all 8 asset requests versioned with one shared token (content-hash fallback inside the image — no .git there); /assets/* immutable for a year.
92 lines
5.2 KiB
Markdown
92 lines
5.2 KiB
Markdown
# Story: Cache Busting (Un-stick the Pages)
|
|
|
|
**Phase:** `33_cache_busting/` · **E2E:** `tests/e2e/test_cache_busting.py`
|
|
|
|
## Narrative
|
|
|
|
As **anyone using Brain of Reese**, I want a new deploy to be **visible
|
|
without a hard refresh**. Today the five HTML pages reference their CSS/JS
|
|
with no version at all, so the browser keeps serving stale assets long
|
|
after the app has moved on — the "the pages are too sticky" report. I want
|
|
a deploy (a commit) to change what the browser fetches, automatically,
|
|
with no new services and no CDN.
|
|
|
|
- **Given** the app has been redeployed (a new commit)
|
|
- **When** I open — or revisit — any page
|
|
- **Then** the page itself is always revalidated (never served from cache
|
|
unchecked), its assets are fetched from **versioned URLs**
|
|
(`?v=<token>` — the git short SHA of the deploy, so each commit changes
|
|
them), and the API — the SSE chat stream in particular — is left
|
|
byte-for-byte alone.
|
|
|
|
## Acceptance criteria
|
|
1. **HTML always revalidates.** All five pages (`/`, `/index.html`,
|
|
`/sources.html`, `/document.html`, `/login.html`, `/tuning.html`) ship
|
|
`Cache-Control: no-cache` (the page is the cheap redirector to the
|
|
long-cached assets).
|
|
2. **Assets are versioned.** Every local `href`/`src` asset reference on
|
|
those pages carries `?v=<token>` — rewritten in flight by one
|
|
middleware (a small regex over `href`/`src` `assets/…` refs, leading
|
|
slash optional, idempotent: an existing query string or fragment is
|
|
never double-tagged, and non-asset references pass through). The token
|
|
is non-empty and **stable across requests** within a process (one token
|
|
per process, computed once).
|
|
3. **Immutable assets.** `/assets/*` responses ship
|
|
`Cache-Control: public, max-age=31536000, immutable` — safe precisely
|
|
because the URL carries the token (a deploy changes the token, hence
|
|
the URL). The unversioned path keeps resolving (the static mount
|
|
ignores the query string), so old tabs and direct links still work.
|
|
4. **The token is the deploy.** `app/core/caching.py::asset_version()`:
|
|
a git checkout (the project root next to `frontend/` has a `.git` —
|
|
the homelab reality) → `git rev-parse --short HEAD` with a 5 s timeout
|
|
(a deploy must not hang a boot); no `.git`, git missing, non-zero
|
|
exit, timeout, or empty output → the first **12 hex chars** of SHA-256
|
|
over the sorted `relpath:mtime_ns:size` of every regular file under
|
|
`frontend/`; a missing/empty static dir → `"dev"`. Cached with
|
|
`functools.cache` (== `lru_cache(maxsize=None)`) — zero per-request
|
|
git/file cost. A new commit or a frontend content change flips the
|
|
token on the next process start.
|
|
5. **The API is untouched.** The middleware touches exactly two response
|
|
shapes (the page paths: body rewrite + `no-cache`; `/assets/*`:
|
|
header only). Everything else — all of `/api/*`, including the SSE
|
|
chat stream — passes through with no header changes and no body read
|
|
(SSE keeps the `no-cache` its endpoint sets itself). The no-CDN
|
|
integration test stays green (the rewritten references are
|
|
same-origin).
|
|
6. **Quality gates.** Unit (`tests/unit/test_caching.py`): the token's
|
|
git path, fallback path (stable across an unchanged tree, flips on a
|
|
touch/modify + `cache_clear()`), failure path (git raising → content
|
|
hash, no exception), empty-dir `"dev"`; the pure `rewrite_asset_refs`
|
|
(versioned `href`/`src`, no-leading-slash refs, module scripts,
|
|
idempotency, `#fragment`/existing-query left alone, non-asset refs
|
|
untouched). Integration (`tests/integration/test_api.py`): every page
|
|
`no-cache` + versioned references, asset headers, no `cache-control`
|
|
injected on `/api/health`, SSE chat tests unchanged and green.
|
|
Coverage: `app/core/caching.py` 100 %, `app/` > 90 % (TOTAL ≥
|
|
pre-change).
|
|
7. **E2E (this story's gate).** `tests/e2e/test_cache_busting.py`, run in
|
|
isolation: a real (fresh-profile) Chromium asserts the **wire truth**
|
|
— the document responses are `no-cache`, the CSS/JS request URLs the
|
|
browser actually makes carry one shared token (matching this
|
|
checkout's `asset_version()`), the asset responses are immutable for a
|
|
year, and `/api/*` — including a live SSE chat turn that streams
|
|
deltas and completes with `done` — is unaffected (mock LLM, no live
|
|
aipi; `podman compose up -d db` for the chat check).
|
|
|
|
## Playwright Mapping Rule
|
|
`tests/e2e/test_cache_busting.py` — run in isolation (Chromium +
|
|
`podman compose up -d db`; mock LLM via the shared `conftest.py` session
|
|
app; no per-module overrides needed):
|
|
|
|
1. `test_html_pages_are_no_cache_and_versioned` → AC 1 + 2 + 3 (the `/`
|
|
document response is `no-cache`; the `styles.css` request URL carries
|
|
`?v=<token>` and its response is `immutable` + `max-age=31536000`;
|
|
the `app.js` request URL carries the **same** token; the served HTML
|
|
carries no unversioned `"/assets/styles.css"` reference).
|
|
2. `test_other_pages_share_the_token` → AC 1 + 2 (the `/sources.html` and
|
|
`/login.html` document responses are `no-cache`; both pages'
|
|
stylesheet requests carry the same token).
|
|
3. `test_api_responses_unaffected` → AC 5 (`/api/health` has no
|
|
`cache-control` injected — the endpoint's baseline headers only; the
|
|
SSE chat POST still streams deltas and completes with `done`).
|