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:
parent
2a9e993d5d
commit
09f9de4636
8 changed files with 238 additions and 5 deletions
27
make_post_sell/lib/cache_version.py
Normal file
27
make_post_sell/lib/cache_version.py
Normal 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"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }}" />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue