feat: deploy + reforge bust client ring cache (HTML + localStorage)

Two paths to stale client state, both closed:

1. HTML cache — content.py and product.py now send Cache-Control:
   no-store, must-revalidate on responses. Browsers were holding
   onto rendered sidebar HTML from before the pocket fix deployed,
   producing phantom 'this didn't work' reports.

2. localStorage cache — shop.json_discovery_ring + GIT_HASH are
   hashed into a short cache_version token, stamped on every page
   (<meta name='mps-cache-version'>) and every watch_json response.
   watch.js stores it in localStorage; on page load and every SPA
   nav, mismatch triggers removal of watchRing, watchRingPosition,
   watchRingHistory, watchRingLoops, watchQueue before anything
   reads them.

watch_json error responses (404 no media, 403 not public) also
carry cache_version so clients can flush even when the target
product can't be played.

Tests:
  - test_models.py TestCacheVersion: 6 unit tests (stability, ring
    content change, ring order change, empty ring, none shop,
    GIT_HASH flip via patch).
  - test_functional.py: 3 functional tests (content page sends
    no-store + meta tag, watch_json error carries cache_version,
    cache_version shifts after reforge).

All 370 model+integration tests + 8 new functional tests green.
This commit is contained in:
russell@unturf.com 2026-04-21 18:58:30 -04:00
parent 2a9e993d5d
commit 09f9de4636
8 changed files with 238 additions and 5 deletions

View file

@ -0,0 +1,27 @@
import hashlib
from ..views.version import GIT_HASH
def compute_cache_version(shop):
"""Opaque token that changes when deploys land or the ring is reforged.
Clients compare this to their localStorage copy; mismatch means
cached ring state (ringProductIds, ringPosition, ringHistory) is
stale and should be dropped before the next SPA navigation.
Inputs:
GIT_HASH shifts on every deploy
shop.json_discovery_ring shifts on every reforge (including
product adds/removes/visibility flips that trigger reforge)
"""
ring_str = (shop.json_discovery_ring or "") if shop is not None else ""
digest = hashlib.md5(f"{GIT_HASH}:{ring_str}".encode()).hexdigest()
return digest[:12]
def apply_no_store_headers(response):
"""Tell browsers not to cache this HTML response — always revalidate."""
response.headers["Cache-Control"] = "no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"

View file

@ -38,6 +38,31 @@
var karaokeEligible = false;
var karaokeProcessing = false;
// --- Cache version: flushes stale ring state on deploy / reforge ---
// Server stamps every page (meta[name="mps-cache-version"]) and every
// watch_json response. Stored client version is compared on each
// page load and SPA nav; mismatch drops cached ringProductIds,
// ringPosition, ringHistory, and ringLoops before anything reads them.
function enforceCacheVersion(serverVersion) {
if (!serverVersion) return;
var stored = null;
try { stored = localStorage.getItem('watchCacheVersion'); } catch (e) {}
if (stored && stored !== serverVersion) {
try {
localStorage.removeItem('watchRing');
localStorage.removeItem('watchRingPosition');
localStorage.removeItem('watchRingHistory');
localStorage.removeItem('watchRingLoops');
localStorage.removeItem('watchQueue');
} catch (e) {}
}
try { localStorage.setItem('watchCacheVersion', serverVersion); } catch (e) {}
}
(function initialCacheCheck() {
var meta = document.querySelector('meta[name="mps-cache-version"]');
if (meta) enforceCacheVersion(meta.getAttribute('content'));
})();
// --- Ring state (persisted in localStorage) ---
var ringProductIds = [];
var ringPosition = 0;
@ -745,6 +770,10 @@
// --- Update page content (title, description, related, URL) ---
function updatePageContent(data) {
// Server may have deployed or reforged since last nav — drop
// stale ring state before applying new data.
if (data.cache_version) enforceCacheVersion(data.cache_version);
// Update ring and mod status from server response
if (data.ring && data.ring.length) {
ringProductIds = data.ring;

View file

@ -63,6 +63,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="{{ theme_color }}">
<meta charset="utf-8">
{% if cache_version %}
<meta name="mps-cache-version" content="{{ cache_version }}">
{% endif %}
{% if request.is_saas_domain == false and request.shop and request.shop.favicon %}
<link rel="icon" href="{{ request.shop_cdn_endpoint }}/{{ request.shop.uuid_str }}/meta/shop-favicon?ts={{ request.shop.updated_timestamp }}" />

View file

@ -3155,6 +3155,102 @@ class AuthenticatedFunctionalTests(_AuthenticatedBase):
self.assertFalse(data["valid"])
self.assertIn(stale_id, data["stale"])
def test_content_page_sets_no_store_cache_header(self):
"""Content page HTML must not be cached by the browser."""
import json as _json
shop = self._create_shop_helper(
shop_params={**self.shop1_params, "name": "cache-header-content-shop"}
)
shop_id = str(shop.id)
self.testapp.post(
f"/s/{shop_id}/settings",
{"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"},
status=302,
)
self.testapp.post(
"/c/new",
{"title": "Cache Header Test", "description": "tests cache header", "submit": True},
)
products = get_all_products(self.dbsession).all()
product = products[0]
product._file_metadata = {"product": {"extension": "mp4", "content_type": "video/mp4"}}
product.json_file_metadata = _json.dumps(product._file_metadata)
product.visibility = 1
self.dbsession.flush()
product_id = str(product.id)
product_slug = product.slug
transaction.commit()
res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
self.assertIn("no-store", res.headers.get("Cache-Control", ""))
# meta tag with cache version present
self.assertIn('name="mps-cache-version"', res.body.decode())
def test_watch_json_includes_cache_version(self):
"""watch_json response carries cache_version for client compare."""
import json as _json
shop = self._create_shop_helper(
shop_params={**self.shop1_params, "name": "cache-header-watch-shop"}
)
shop_id = str(shop.id)
self.testapp.post(
f"/s/{shop_id}/settings",
{"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"},
status=302,
)
self.testapp.post(
"/c/new",
{"title": "Watch JSON Cache", "description": "desc", "submit": True},
)
products = get_all_products(self.dbsession).all()
product = products[0]
product._file_metadata = {"product": {"extension": "mp4", "content_type": "video/mp4"}}
product.json_file_metadata = _json.dumps(product._file_metadata)
product.visibility = 1
self.dbsession.flush()
product_id = str(product.id)
transaction.commit()
# Product has no uploaded media → 404, but cache_version still
# comes back on the error response so clients can flush state.
res = self.testapp.get(f"/watch/{product_id}/json", status=404)
self.assertIn("cache_version", res.json)
self.assertTrue(len(res.json["cache_version"]) > 0)
def test_cache_version_changes_after_reforge(self):
"""Reforging the ring changes cache_version — clients will flush."""
shop = self._create_shop_helper(
shop_params={**self.shop1_params, "name": "cache-version-change-shop"}
)
shop_id = str(shop.id)
self.testapp.post(
f"/s/{shop_id}/settings",
{"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"},
status=302,
)
self.testapp.post(
f"/p/new?shop_id={shop_id}", self.product1_params
).follow()
from ..models.shop import reforge_discovery_ring
from ..lib.cache_version import compute_cache_version
self.dbsession.expire(shop)
reforge_discovery_ring(shop)
self.dbsession.flush()
v1 = compute_cache_version(shop)
# Add a second product — next reforge will produce a different ring
self.testapp.post(
f"/p/new?shop_id={shop_id}",
{**self.product1_params, "title": "second product for cache version"},
).follow()
reforge_discovery_ring(shop)
self.dbsession.flush()
v2 = compute_cache_version(shop)
self.assertNotEqual(v1, v2)
def test_ring_health_shop_not_found(self):
"""Unknown shop_id returns 404."""
self._create_shop_helper(

View file

@ -2688,6 +2688,63 @@ class TestValidateDiscoveryRing(unittest.TestCase):
self.assertEqual(health["public_count"], 2)
class TestCacheVersion(unittest.TestCase):
"""Test the cache_version helper used to invalidate client ring state."""
def _make_shop(self, ring=None):
shop = Shop("cv-shop", "555-555-5555", "123 CV St", "desc")
if ring is not None:
shop.discovery_ring = ring
return shop
def test_stable_for_unchanged_inputs(self):
"""Same GIT_HASH + same ring → same cache_version."""
from ..lib.cache_version import compute_cache_version
shop = self._make_shop(ring=["a", "b", "c"])
self.assertEqual(
compute_cache_version(shop),
compute_cache_version(shop),
)
def test_changes_when_ring_content_changes(self):
"""Any change to the ring list shifts the cache_version."""
from ..lib.cache_version import compute_cache_version
v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"]))
v2 = compute_cache_version(self._make_shop(ring=["a", "b", "d"]))
self.assertNotEqual(v1, v2)
def test_changes_when_ring_order_changes(self):
"""Reordering the ring (reforge) shifts the cache_version."""
from ..lib.cache_version import compute_cache_version
v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"]))
v2 = compute_cache_version(self._make_shop(ring=["c", "b", "a"]))
self.assertNotEqual(v1, v2)
def test_none_shop_returns_token(self):
"""Passing None shop does not crash — returns a stable token."""
from ..lib.cache_version import compute_cache_version
v = compute_cache_version(None)
self.assertIsInstance(v, str)
self.assertTrue(len(v) > 0)
def test_empty_ring_is_not_error(self):
"""Shop with no ring still gets a cache_version."""
from ..lib.cache_version import compute_cache_version
v = compute_cache_version(self._make_shop(ring=[]))
self.assertIsInstance(v, str)
self.assertTrue(len(v) > 0)
def test_changes_with_git_hash(self):
"""GIT_HASH flip changes cache_version (simulated via patch)."""
from ..lib import cache_version as cv_module
shop = self._make_shop(ring=["a", "b"])
with mock.patch.object(cv_module, "GIT_HASH", "hash-one"):
v1 = cv_module.compute_cache_version(shop)
with mock.patch.object(cv_module, "GIT_HASH", "hash-two"):
v2 = cv_module.compute_cache_version(shop)
self.assertNotEqual(v1, v2)
class TestAsyncDiscoveryRing(unittest.TestCase):
"""Test the async ring reforge with dirty bit debounce."""

View file

@ -2,6 +2,7 @@ from pyramid.view import view_config
from . import get_referer_or_home
from ..models.product import get_media_type
from ..lib.cache_version import compute_cache_version, apply_no_store_headers
from pyramid.httpexceptions import HTTPFound
@ -82,6 +83,11 @@ def content(request):
else:
related_products = get_related_products(product)
# Dynamic content page — never cache HTML at the browser. Sidebar
# (Up Next) reflects live ring state; stale HTML produces "pocket"
# confusion where the sidebar disagrees with reality.
apply_no_store_headers(request.response)
# Generate CDN URLs for karaoke tracks if available
instrumentals_url = None
vocals_url = None
@ -107,6 +113,7 @@ def content(request):
"comments": comments,
"shop": product.shop,
"related_products": related_products,
"cache_version": compute_cache_version(product.shop),
"instrumentals_url": instrumentals_url,
"vocals_url": vocals_url,
"karaoke_eligible": karaoke_eligible,

View file

@ -16,6 +16,7 @@ from ..models.inventory import Inventory
from ..lib.currency import validate_float, cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
from ..lib.cache_version import compute_cache_version, apply_no_store_headers
def checkbox_to_bool(checkbox):
@ -118,6 +119,10 @@ def product(request):
"is_current": i == 0,
})
# Dynamic product page — never cache HTML at the browser. Same
# reason as content.py: Up Next sidebar must reflect live ring state.
apply_no_store_headers(request.response)
return {
"product": product,
"product_size": product_size,
@ -125,6 +130,7 @@ def product(request):
"comments": comments,
"shop": product.shop,
"related_products": related_products,
"cache_version": compute_cache_version(product.shop),
"price_history": price_history,
}

View file

@ -4,6 +4,7 @@ from pyramid.view import view_config
from ..models.product import get_media_type, get_related_products, get_ring_related_products
from ..models.shop import get_shop_by_id, validate_discovery_ring
from ..lib.cache_version import compute_cache_version
from ..lib.currency import cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
@ -16,30 +17,36 @@ def watch_json(request):
product = request.product
# Compute cache version up front so every response (including errors)
# carries it — lets the client flush stale localStorage ring state
# even when the current product can't be played.
shop_for_cache = request.shop if request.shop else (product.shop if product else None)
cache_version = compute_cache_version(shop_for_cache)
if not product:
request.response.status_int = 404
return {"error": "Product not found"}
return {"error": "Product not found", "cache_version": cache_version}
if not request.shop or not request.shop.watch_mode_enabled:
request.response.status_int = 404
return {"error": "Watch mode not enabled"}
return {"error": "Watch mode not enabled", "cache_version": cache_version}
# Only allow public products (visibility == 1)
if product.visibility != 1:
request.response.status_int = 403
return {"error": "Not publicly available"}
return {"error": "Not publicly available", "cache_version": cache_version}
# Determine which file to use
if product.is_sellable:
file_key = "preview"
if file_key not in product.extensions:
request.response.status_int = 403
return {"error": "No preview available"}
return {"error": "No preview available", "cache_version": cache_version}
else:
file_key = "product"
if file_key not in product.extensions:
request.response.status_int = 404
return {"error": "No media file available"}
return {"error": "No media file available", "cache_version": cache_version}
# Get extension and detect media type
extension = product.extensions.get(file_key)
@ -204,6 +211,7 @@ def watch_json(request):
})
result = {
"cache_version": compute_cache_version(request.shop),
"product_id": str(product.id),
"shop_id": str(product.shop_id),
"title": product.title,