Skip to content

Keep the week-long immutable cache to Vite's hashed /assets/ tree - #388

Merged
Babissimo merged 1 commit into
mainfrom
fix/data-explorer-edge-cache
Sep 15, 2026
Merged

Babissimo merged 1 commit into
mainfrom
fix/data-explorer-edge-cache

Conversation

@Babissimo

Copy link
Copy Markdown
Contributor

Why

deploy/nginx/snippets/spa.conf gives every static file expires 7d with Cache-Control: public, immutable, and Cloudflare honours that for the whole window. That is the right policy for Vite's output, whose names carry a content hash, and the wrong one for every file whose name survives a deploy: the dashboard's theme-boot.js (from dashboard/public/) and everything the data explorer ships, which has no build step.

Today's deploy of #383 showed the failure shape. index.html is never cached, so data.retina.fm served the new markup with the 5 September app.css and app.js under it (cf-cache-status: HIT, age ~20 h, expiring 21 September), and the theme switch rendered as three unstyled slivers. The container held the new files and a cache-busted fetch returned them; the edge did not. Nothing in the pipeline could see it, since every probe of that vhost still answered 200. The two URLs were purged by hand through the Cloudflare API, which is why it renders now.

What

  • spa.conf: immutable only under /assets/, the one tree where the name changes with the content. Every other static file is Cache-Control: no-cache. With Origin Cache Control on (the default for this plan tier) Cloudflare caches and revalidates on every request, and nginx answers a conditional request with 304, so an unchanged file costs a round trip rather than a re-download and a changed one shows through at once. The explorer's vendored libraries lose the long cache too: their names carry no version either, so they cannot be immutable safely. That is roughly 380 KB revalidated per first visit on a low-traffic public page.
  • backend/tests/test_nginx_static_cache.py: on the rendered config, immutable is confined to /assets/ and the catch-all follows it in every SPA vhost. nginx takes the first regex location that matches, so a catch-all placed first would swallow /assets/ and drop the week. The rendering fixture moves to tests/nginx_helpers.py so this and test_nginx_rewrite_ordering.py share it.
  • deploy/staging-smoke-test.sh: three live-header probes in the shared-config section, via a new check_header_value helper: dash theme-boot.js and data app.css must say no-cache, and one hashed map asset (found from the page) must say immutable.
  • data-explorer/README.md states the policy its files are served under.

Verified

  • pytest tests/test_nginx_static_cache.py tests/test_nginx_rewrite_ordering.py tests/test_towers_vhost_coverage.py: 10 passed.
  • Rendered the template (TLS off) and ran nginx -t on it in nginx:alpine: syntax ok. The data vhost renders = /index.html, then the /assets/ location, then the catch-all, in that order.
  • pre-commit run --all-files clean; bash -n and shellcheck on the smoke script (info-level notes only, matching the existing helpers).
  • The modified smoke script run against live staging before this deploys: 25 passed, 1 failed, 5 warned. The failure is dash theme-boot.js revalidates, which is the check doing its job against today's config; data app.css revalidates warns locally because the DNS guard uses getent, which macOS lacks, and passes in CI. hashed /assets/ file is immutable passes.

The staging chain never runs on a PR, so the smoke probes are first exercised by the merge run.

ClickUp Edge keeps unhashed static files for a week, so a deploy of the data explorer is invisible until purged.

🤖 Generated with Claude Code

@claude

This comment has been minimized.

spa.conf gave every static file `expires 7d` with `public, immutable`,
and Cloudflare honours that for the whole window. It is the right policy
for Vite's output, whose names carry a content hash, and the wrong one
for every file whose name survives a deploy: the dashboard's
theme-boot.js and everything the data explorer ships, which has no build
step. The 2026-09-15 deploy of the explorer's theme switch showed the
failure shape: index.html is never cached, so the edge served the new
markup with the 5 September app.css and app.js under it, and the switch
rendered as three unstyled slivers until the two URLs were purged by
hand. Nothing in the pipeline could see it; every probe of that vhost
still read a healthy 200.

Immutable now applies only under /assets/, the one tree where the name
changes with the content. Everything else static is `no-cache`: with
Origin Cache Control on, Cloudflare caches and revalidates on every
request, and nginx answers a conditional request with 304, so the cost
of an unchanged file is a round trip rather than a re-download, and a
changed one shows through at once. The data explorer's vendored
libraries lose the long cache too, since their names carry no version
either.

Three assertions come with it. A rendered-config test pins that
immutable is confined to /assets/ and that the catch-all follows it
(nginx takes the first regex location that matches, so a catch-all
placed first would swallow /assets/ too). The staging smoke probes the
live headers on dash's theme-boot.js, the explorer's app.css and one
hashed map asset, each with a never-seen query string so the request
reaches nginx rather than a copy the edge stored under the old headers;
run against staging before this change it fails on theme-boot.js, which
is the point. The rendering fixture moves to tests/nginx_helpers.py so
both nginx test files share it.

The edge does not forget on its own: a copy it already holds under the
old headers stays until it expires or is purged, so the first deploy of
this is followed by a purge of the affected URLs.

ClickUp 123zgec2zqr.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Babissimo
Babissimo force-pushed the fix/data-explorer-edge-cache branch from b6012d8 to aebbb35 Compare September 15, 2026 12:55
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 2m 39s —— View job


Review: Keep the week-long immutable cache to Vite's hashed /assets/ tree

  • Gather context (read changed files, diff against origin/main)
  • Understand the request (code review via /code-review)
  • Review deploy/nginx/snippets/spa.conf changes
  • Review backend/tests/test_nginx_static_cache.py and nginx_helpers.py
  • Review deploy/staging-smoke-test.sh changes
  • Review data-explorer/README.md changes
  • Post final review feedback

Summary: solid, well-scoped fix with tests and docs that back up the stated behavior. No blocking issues found.

deploy/nginx/snippets/spa.conf (lines 19–33) — the two-location split is correct. nginx picks the first matching regex location in declaration order (not longest-match; that's prefix-location behavior), so ^/assets/... immutable has to come before the bare \.(js|css|...)$ catch-all, and it does. I verified there's no other expires directive anywhere under deploy/nginx/ (grep -rn expires deploy/nginx/ → only the new line 25), so dropping expires from the catch-all location is safe — there's nothing for it to inherit. I also confirmed both frontend/vite.config.js and dashboard/vite.config.js use Vite's default output layout (no custom assetsDir), so every hashed bundle really does land under /assets/ and the "only /assets/ carries a content hash" premise holds.

backend/tests/test_nginx_static_cache.py + nginx_helpers.py — asserting on the rendered config (post RETINA_INCLUDE expansion) is right, since location order only exists after expansion. I checked deploy/nginx/nginx.conf.template for other location blocks that could pollute the _STATIC substring match (\.(js|css|) used to select static-file locations — the only other locations in the template are /ws/, /api/*, /towers, and the towers-proxy set, none of which match, so the "12 = 6 vhosts × 2 locations, paired assets-then-catch-all" assumption is sound. grep -c "spa.conf" nginx.conf.template confirms exactly 6 includes. The extraction of render()/locations() into nginx_helpers.py is a clean, faithful refactor (same expand_includes(path, root, flags) call signature as before) and test_towers_vhost_coverage.py doesn't depend on it, so nothing else was at risk of breaking.

deploy/staging-smoke-test.shcheck_header_value/check_header_value_if_dns mirror the existing check_header/check_header_if_dns pattern exactly (same DNS-guard shape, same lowercase-and-grep -qF idiom). The cache-busting query string (BUST="smoke=$(date +%s)$RANDOM") is a sound way to force an edge MISS and inspect nginx's own header rather than whatever the edge already cached — consistent with how the bug was actually diagnosed per the PR description. The MAP_ASSET discovery has a non-crashing FAIL fallback if no /assets/index-*.js is found on the page, which is appropriate defensive handling for a scraped value.

data-explorer/README.md — accurately documents the new policy.

Minor, non-blocking observations:

  1. deploy/staging-smoke-test.shMAP_ASSET discovery hard-codes Vite's default entry-chunk naming (/assets/index-<hash>.js). This is a reasonable assumption today (confirmed against frontend/vite.config.js's default rollupOptions), but if the entry chunk is ever renamed or code-split differently, the probe silently stops finding an asset and falls through to the FAIL branch rather than testing a different hashed file. Not a correctness issue now, just a note for future maintainers.
  2. The smoke probes only verify that nginx sends the correct Cache-Control header on a forced-miss request — they don't verify Cloudflare's actual edge behavior end-to-end (e.g., that a previously cached copy gets revalidated rather than served stale). That's consistent with how the rest of the script works and is a reasonable scope boundary, but worth knowing it doesn't reproduce the exact "stale edge copy" failure mode from the incident — only the config precondition that prevents it going forward.

Verification performed: read the full diff (git diff HEAD~1 HEAD), traced the rendered-config test logic by hand against nginx.conf.template and render-nginx-config.py, and grepped the repo to confirm the premises the PR relies on (no other expires directives, no other cache-relevant locations, no custom Vite assetsDir, exactly 6 spa.conf includes). I was not able to execute pytest or the smoke script in this sandbox (command execution required approval not available in this headless run), so I did not independently reproduce the "10 passed" / live-staging results — the PR description's own verification section covers that gap.

No changes requested.

@Babissimo
Babissimo merged commit 8074d8f into main Sep 15, 2026
14 checks passed
Babissimo added a commit that referenced this pull request Sep 15, 2026
#388 switched every static file outside /assets/ to `no-cache`, on the
reading that Cloudflare would cache and revalidate at the edge while the
browser revalidated with a 304. Half of that is what happens. The edge
does revalidate, but the zone's Browser Cache TTL is four hours, and
with Origin Cache Control on Cloudflare stamps that on any cacheable
response whose own max-age is lower: staging answered
`cache-control: max-age=14400` for theme-boot.js and app.css the moment
#388 deployed. A browser would sit on a stale copy for four hours after
a deploy, and the two smoke probes that assert `no-cache` failed, which
skipped the production deploy.

`no-store, no-cache` is the form this zone passes through untouched;
index.html has been served that way throughout and arrives intact as
DYNAMIC. So the unhashed files take the same policy as the page that
loads them. The price is that a browser re-fetches them on every visit
rather than revalidating, about 450 KB for the data explorer including
its vendored libraries, on a low-traffic public page. The smoke probes
and the rendered-config test now assert `no-store`.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant