From 47a53703b307b9ae81f11f837b426a52336f184b Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 14 Jul 2026 16:19:01 -0700 Subject: [PATCH 1/2] Serve cache_no_user_data view bodies from a shared cache - SPA shells render no user-specific data, so one rendered body serves every client. - cache_no_user_data stores that body in process_cache behind a single-flight election. - Refresh is stale-while-revalidate: requests keep serving the stored copy while the election winner re-renders in a background thread. - Only a cold miss blocks on a render. - The refresh fires at a randomized moment before the deadline, so the entry never hard-expires and refreshes do not fire in lockstep across keys. - UserAuthView drops its server-side authenticated redirect, so it renders no user-specific data. - app.js redirects authenticated users to their landing page client-side instead. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PeRsMyx4NSFFKrWpD4sZ2o --- kolibri/core/decorators.py | 257 +++++++++++++++++----- kolibri/core/test/test_decorators.py | 170 ++++++++++++++ kolibri/core/views.py | 3 +- kolibri/plugins/coach/views.py | 3 +- kolibri/plugins/device/views.py | 3 +- kolibri/plugins/facility/views.py | 3 +- kolibri/plugins/learn/views.py | 5 +- kolibri/plugins/policies/views.py | 3 +- kolibri/plugins/pwa/views.py | 10 +- kolibri/plugins/user_auth/frontend/app.js | 12 +- kolibri/plugins/user_auth/views.py | 13 +- kolibri/plugins/user_profile/views.py | 3 +- 12 files changed, 399 insertions(+), 86 deletions(-) diff --git a/kolibri/core/decorators.py b/kolibri/core/decorators.py index e0886cddf7e..dd68609963a 100644 --- a/kolibri/core/decorators.py +++ b/kolibri/core/decorators.py @@ -2,16 +2,30 @@ Modified and extended from https://github.com/camsaul/django-rest-params/blob/master/django_rest_params/decorators.py """ +import functools import hashlib -from threading import local - -from django.core.cache import cache +import logging +import random +import re +import threading +import time +from copy import deepcopy +from io import BytesIO + +from django.core.handlers.wsgi import WSGIRequest +from django.db import connections +from django.utils import translation +from django.utils.cache import add_never_cache_headers +from django.utils.cache import get_conditional_response from django.utils.cache import patch_response_headers -from django.views.decorators.http import etag +from django.utils.text import compress_string from rest_framework.exceptions import APIException from rest_framework.views import APIView from kolibri import __version__ as kolibri_version +from kolibri.core.utils.cache import process_cache + +logger = logging.getLogger(__name__) TRUE_VALUES = ("1", "true") FALSE_VALUES = ("0", "false") @@ -311,62 +325,199 @@ def initial(self, request, *args, **kwargs): return _params -def cache_no_user_data(view_func): +# Seconds a freshly stored body is served before a refresh is triggered. +BODY_CACHE_REFRESH = 15 +# Refresh deadlines are jittered back by up to this much, so the bodies warming +# stores in one burst do not all come due in the same instant. +BODY_CACHE_REFRESH_JITTER = 5 +# How long the refresh election is held before another request may take it over, +# in case the elected refresher dies mid-render. +BODY_CACHE_LOCK_TIMEOUT = 30 + + +def _spawn(target): + # Seam for the background refresh, so tests can run it synchronously. + threading.Thread(target=target, daemon=True).start() + + +_GZIP_RE = re.compile(r"\bgzip\b") + + +def _accepts_gzip(request): + # No Accept-Encoding means no preference, so it gets the gzip like everyone + # else; only an explicit header without gzip gets the plain body. Mirrors the + # negotiation kolibri.utils.kolibri_whitenoise does for static assets. + accept_encoding = request.META.get("HTTP_ACCEPT_ENCODING", "*") + return accept_encoding == "*" or bool(_GZIP_RE.search(accept_encoding)) + + +# Carried from a live request into its off-thread refresh so the refreshed body +# matches what the request renders. Excludes cookies and other per-user headers - +# the cached body must stay user-independent. +_CARRIED_ENVIRON_KEYS = ( + "SERVER_NAME", + "SERVER_PORT", + "HTTP_HOST", + "wsgi.url_scheme", +) + + +def _build_request(path, base_environ=None): + # Bare GET request for rendering a cached view outside the request cycle. + environ = { + "REQUEST_METHOD": "GET", + "PATH_INFO": path, + "SCRIPT_NAME": "", + "SERVER_NAME": "localhost", + "SERVER_PORT": "80", + "SERVER_PROTOCOL": "HTTP/1.1", + "QUERY_STRING": "", + "wsgi.url_scheme": "http", + "wsgi.input": BytesIO(b""), + } + if base_environ is not None: + for key in _CARRIED_ENVIRON_KEYS: + if key in base_environ: + environ[key] = base_environ[key] + request = WSGIRequest(environ) + # The decorator drops the session; give it one to drop. + request.session = {} + return request + + +class _CachedBody: """ - Set appropriate Vary on headers on a view that specify there is - no user specific data being rendered in the view. - In order to ensure that the correct Vary headers are set, - the session is deleted from the request, as otherwise Vary cookies - will always be set by the Django session middleware. - This should not be used on any view that bootstraps user specific - data into it - this will remove the headers that will make this vary - on a per user basis. + Serve one view's rendered body from a shared cross-process cache, since the + view renders no user-specific data. """ - CACHE_TIMEOUT = 15 - CACHE_KEY_TEMPLATE = "SPA_ETAG_CACHE_{}" - _response = local() - - def render_and_cache(response, cache_key): - if hasattr(response, "render") and callable(response.render): - response.render() - if response.content: - etag = hashlib.md5( - kolibri_version.encode("utf-8") + str(response.content).encode("utf-8") - ).hexdigest() - cache.set(cache_key, etag, CACHE_TIMEOUT) - return etag - else: - return None + def __init__(self, view_class): + self._view_class = view_class + self._dispatch = view_class.dispatch - def calculate_spa_etag(*args, **kwargs): - # Clear the local thread 'response' property - setattr(_response, "response", None) - - request = args[0] - etag = cache.get(CACHE_KEY_TEMPLATE.format(request.path)) - - # Doing this here - will also be the same in inner_func - # required to delete the session for this to work as expected + def __call__(self, view, request, *args, **kwargs): + # Drop the session so the session middleware does not add Vary: Cookie, + # which would split the cache per user. del request.session + body_key, lock_key = self._keys(request.path) + entry = process_cache.get(body_key) + if entry is not None: + variants, refresh_at = entry + if time.time() >= refresh_at and process_cache.add( + lock_key, True, BODY_CACHE_LOCK_TIMEOUT + ): + self._refresh_in_background(request, body_key, lock_key) + return self._conditional(request, self._pick(request, variants)) + # Cold miss: render inline, since there is nothing to serve stale. + return self._conditional( + request, self._render_and_store(view, request, args, kwargs, body_key) + ) - if not etag: - response = view_func(*args, **kwargs) - setattr(_response, "response", response) - etag = render_and_cache(response, CACHE_KEY_TEMPLATE.format(request.path)) - return etag + @staticmethod + def _keys(path): + # One entry per path; language is carried by the path's i18n prefix. + # The entry holds both encodings, so the key does not vary on + # Accept-Encoding - it is negotiated at serve time instead. + digest = hashlib.md5( + "{}:{}".format(kolibri_version, path).encode("utf-8") + ).hexdigest() + return "VIEW_BODY_CACHE_{}".format(digest), "VIEW_BODY_LOCK_{}".format(digest) + + @staticmethod + def _conditional(request, response): + # Honour If-None-Match so a client past the browser-cache window + # revalidates instead of re-fetching the whole body. + return ( + get_conditional_response( + request, etag=response.headers.get("ETag"), response=response + ) + or response + ) - @etag(calculate_spa_etag) - def inner_func(*args, **kwargs): - request = args[0] + @staticmethod + def _pick(request, variants): + plain, gzipped = variants + return gzipped if _accepts_gzip(request) else plain + + @staticmethod + def _finalize(response, content, encoding=None): + response.content = content + if encoding: + response.headers["Content-Encoding"] = encoding + response.headers["Content-Length"] = str(len(content)) + patch_response_headers(response, cache_timeout=BODY_CACHE_REFRESH) + response.headers["Vary"] = "Accept-Encoding" + # Content-based ETag, so conditional GETs 304. Distinct per encoding, + # since the two representations are different bytes. + response.headers["ETag"] = '"{}"'.format( + hashlib.md5(kolibri_version.encode("utf-8") + content).hexdigest() + ) + return response - response = getattr(_response, "response", None) - if not response: - response = view_func(*args, **kwargs) + def _render_and_store(self, view, request, args, kwargs, body_key): + response = self._dispatch(view, request, *args, **kwargs) + if hasattr(response, "render") and callable(response.render): + response.render() + if response.status_code != 200 or not response.content: + add_never_cache_headers(response) + return response + # Both encodings are compressed and stored once here rather than per + # request - that drag is what retired the global gzip middleware. + gzipped = self._finalize( + deepcopy(response), compress_string(response.content), "gzip" + ) + variants = (self._finalize(response, response.content), gzipped) + refresh_at = ( + time.time() + + BODY_CACHE_REFRESH + - random.uniform(0, BODY_CACHE_REFRESH_JITTER) + ) + # timeout=None: the body never hard-expires, so a stale copy is always + # available to serve while one request refreshes it. + process_cache.set(body_key, (variants, refresh_at), None) + return self._pick(request, variants) + + def _refresh_in_background(self, request, body_key, lock_key): + # Off-thread so no request blocks on the render. Build a fresh view and + # request rather than sharing the live ones across threads - the view + # renders from its own ``request`` attribute, so it needs its own view + # instance. Translation is thread-local, so carry the language over. + language = translation.get_language() + path = request.path + base_environ = { + key: request.environ[key] + for key in _CARRIED_ENVIRON_KEYS + if key in request.environ + } + + def run(): + try: + with translation.override(language): + fresh_request = _build_request(path, base_environ) + fresh_view = self._view_class() + fresh_view.setup(fresh_request) + self._render_and_store(fresh_view, fresh_request, (), {}, body_key) + # Best effort: a thread has no caller to propagate to, and a failed + # refresh just means the stale body serves until the next attempt. + except Exception: + logger.warning("Failed to refresh cached view body", exc_info=True) + finally: + process_cache.delete(lock_key) + connections.close_all() + + _spawn(run) + + +def cache_no_user_data(view_class): + """ + View-class decorator: serve the view's body from a shared cache (see + ``_SpaBodyCache``). Must not be used on a view that renders user data. + """ + cache = _CachedBody(view_class) - render_and_cache(response, CACHE_KEY_TEMPLATE.format(request.path)) - patch_response_headers(response, cache_timeout=CACHE_TIMEOUT) - response.headers["Vary"] = "accept-encoding, accept" - return response + @functools.wraps(view_class.dispatch) + def dispatch(self, request, *args, **kwargs): + return cache(self, request, *args, **kwargs) - return inner_func + view_class.dispatch = dispatch + return view_class diff --git a/kolibri/core/test/test_decorators.py b/kolibri/core/test/test_decorators.py index 36892778763..109c10af130 100644 --- a/kolibri/core/test/test_decorators.py +++ b/kolibri/core/test/test_decorators.py @@ -1,7 +1,23 @@ +import gzip +from unittest import mock + +from django.http import HttpResponse +from django.template import engines +from django.template.response import TemplateResponse +from django.test import RequestFactory from django.test import SimpleTestCase +from django.views.generic.base import View +from kolibri.core.decorators import _CachedBody +from kolibri.core.decorators import BODY_CACHE_REFRESH +from kolibri.core.decorators import cache_no_user_data from kolibri.core.decorators import InvalidQueryParamsException from kolibri.core.decorators import ParamValidator +from kolibri.core.utils.cache import process_cache + + +def run_inline(target): + target() class ParamValidatorTestCase(SimpleTestCase): @@ -21,3 +37,157 @@ def test_invalid_bool_param_raises_query_params_exception(self): with self.assertRaises(InvalidQueryParamsException): validator.check_non_tuple_types("yes") + + +class CacheNoUserDataTestCase(SimpleTestCase): + def setUp(self): + process_cache.clear() + self.factory = RequestFactory() + + def _request(self, path="/en/learn/", **extra): + request = self.factory.get(path, **extra) + request.session = {} + return request + + def _view(self): + # Each render returns a distinct body, so a stale read is distinguishable + # from a fresh one. + render_count = [] + + @cache_no_user_data + class CachedView(View): + def dispatch(self, request, *args, **kwargs): + render_count.append(1) + return HttpResponse("body-{}".format(len(render_count))) + + return CachedView().dispatch, render_count + + def _template_view(self): + # The real cached views all return an unrendered TemplateResponse. + @cache_no_user_data + class CachedTemplateView(View): + def dispatch(self, request, *args, **kwargs): + return TemplateResponse( + request, engines["django"].from_string("body-{{ n }}"), {"n": 1} + ) + + return CachedTemplateView().dispatch + + def test_renders_once_then_serves_the_shared_body(self): + view, render_count = self._view() + + first = view(self._request()) + second = view(self._request()) + + self.assertEqual(gzip.decompress(first.content), b"body-1") + self.assertEqual(gzip.decompress(second.content), b"body-1") + self.assertEqual(second["Vary"], "Accept-Encoding") + self.assertEqual(len(render_count), 1) + + # A different path is cached separately. + view(self._request("/en/coach/")) + self.assertEqual(len(render_count), 2) + + def test_serves_stale_body_while_refreshing_in_background(self): + view, render_count = self._view() + base = 1000.0 + stale = base + BODY_CACHE_REFRESH + 100 # well past the refresh deadline + + with mock.patch("kolibri.core.decorators._spawn", run_inline): + with mock.patch("kolibri.core.decorators.time.time", return_value=base): + view(self._request()) # caches body-1 + with mock.patch("kolibri.core.decorators.time.time", return_value=stale): + served = view(self._request()) + with mock.patch( + "kolibri.core.decorators.time.time", return_value=stale + 1 + ): + after = view(self._request()) + + # Entry never hard-expires: stale is served past the deadline, then replaced. + self.assertEqual(gzip.decompress(served.content), b"body-1") + self.assertEqual(gzip.decompress(after.content), b"body-2") + self.assertEqual(len(render_count), 2) + + def test_refresh_deadline_is_jittered_back_from_the_full_window(self): + view, _ = self._view() + + with mock.patch("kolibri.core.decorators.time.time", return_value=1000.0): + with mock.patch("kolibri.core.decorators.random.uniform", return_value=2.0): + view(self._request()) + + # Bodies stored in one burst must not all come due in the same instant. + _, refresh_at = process_cache.get(_CachedBody._keys("/en/learn/")[0]) + self.assertEqual(refresh_at, 1000.0 + BODY_CACHE_REFRESH - 2.0) + + def test_serves_304_to_a_client_that_already_has_the_body(self): + view, render_count = self._view() + + first = view(self._request()) + etag = first["ETag"] + conditional = view(self._request(HTTP_IF_NONE_MATCH=etag)) + + # Client already holds the body: revalidate to 304, no re-fetch, no re-render. + self.assertEqual(conditional.status_code, 304) + self.assertFalse(conditional.content) + self.assertEqual(len(render_count), 1) + + def test_stores_and_serves_a_gzipped_body(self): + view, _ = self._view() + + response = view(self._request()) + + self.assertEqual(response["Content-Encoding"], "gzip") + self.assertEqual(response["Content-Length"], str(len(response.content))) + self.assertEqual(gzip.decompress(response.content), b"body-1") + + def test_serves_the_plain_body_to_a_client_that_does_not_accept_gzip(self): + view, render_count = self._view() + + view(self._request()) + response = view(self._request(HTTP_ACCEPT_ENCODING="identity")) + + # Both encodings come from one stored render, as for static assets. + self.assertFalse(response.has_header("Content-Encoding")) + self.assertEqual(response.content, b"body-1") + self.assertEqual(response["Content-Length"], str(len(b"body-1"))) + self.assertEqual(len(render_count), 1) + + def test_each_encoding_gets_its_own_etag(self): + view, _ = self._view() + + gzipped = view(self._request()) + plain = view(self._request(HTTP_ACCEPT_ENCODING="identity")) + + # Distinct representations must not share an ETag, or a conditional GET + # would 304 a client holding the other encoding. + self.assertNotEqual(gzipped["ETag"], plain["ETag"]) + self.assertEqual(gzipped["Vary"], "Accept-Encoding") + self.assertEqual(plain["Vary"], "Accept-Encoding") + + def test_renders_and_stores_both_encodings_of_a_template_response(self): + view = self._template_view() + + gzipped = view(self._request()) + plain = view(self._request(HTTP_ACCEPT_ENCODING="identity")) + + # An unrendered TemplateResponse must survive rendering, copying for the + # second encoding, and the round trip through the cache. + self.assertEqual(gzip.decompress(gzipped.content), b"body-1") + self.assertEqual(plain.content, b"body-1") + self.assertNotEqual(gzipped["ETag"], plain["ETag"]) + + def test_conditional_get_matches_only_the_encoding_the_client_holds(self): + view, _ = self._view() + + plain_etag = view(self._request(HTTP_ACCEPT_ENCODING="identity"))["ETag"] + revalidated = view( + self._request( + HTTP_ACCEPT_ENCODING="identity", HTTP_IF_NONE_MATCH=plain_etag + ) + ) + crossed = view(self._request(HTTP_IF_NONE_MATCH=plain_etag)) + + self.assertEqual(revalidated.status_code, 304) + # A gzip client holding the plain ETag must get the body, not a 304. + self.assertEqual(crossed.status_code, 200) + self.assertEqual(gzip.decompress(crossed.content), b"body-1") diff --git a/kolibri/core/views.py b/kolibri/core/views.py index 7e03e14d156..be78437b21e 100644 --- a/kolibri/core/views.py +++ b/kolibri/core/views.py @@ -8,7 +8,6 @@ from django.urls import is_valid_path from django.urls import reverse from django.urls import translate_url -from django.utils.decorators import method_decorator from django.utils.http import url_has_allowed_host_and_scheme from django.utils.translation import check_for_language from django.utils.translation import gettext_lazy as _ @@ -194,7 +193,7 @@ def get_redirect_url(self, *args, **kwargs): return super().get_redirect_url(*args, **kwargs) -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class UnsupportedBrowserView(TemplateView): template_name = "kolibri/unsupported_browser.html" diff --git a/kolibri/plugins/coach/views.py b/kolibri/plugins/coach/views.py index dd904872adc..dd5a49bbf38 100644 --- a/kolibri/plugins/coach/views.py +++ b/kolibri/plugins/coach/views.py @@ -1,9 +1,8 @@ -from django.utils.decorators import method_decorator from django.views.generic.base import TemplateView from kolibri.core.decorators import cache_no_user_data -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class CoachView(TemplateView): template_name = "coach/coach.html" diff --git a/kolibri/plugins/device/views.py b/kolibri/plugins/device/views.py index 7b974239908..3a35f1b246f 100644 --- a/kolibri/plugins/device/views.py +++ b/kolibri/plugins/device/views.py @@ -1,9 +1,8 @@ -from django.utils.decorators import method_decorator from django.views.generic.base import TemplateView from kolibri.core.decorators import cache_no_user_data -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class DeviceManagementView(TemplateView): template_name = "device_management.html" diff --git a/kolibri/plugins/facility/views.py b/kolibri/plugins/facility/views.py index 541850df23e..90637637e9a 100644 --- a/kolibri/plugins/facility/views.py +++ b/kolibri/plugins/facility/views.py @@ -9,7 +9,6 @@ from django.shortcuts import get_object_or_404 from django.template.defaultfilters import slugify from django.utils import translation -from django.utils.decorators import method_decorator from django.utils.translation import get_language_from_request from django.utils.translation import pgettext from django.views.generic.base import TemplateView @@ -37,7 +36,7 @@ logger = logging.getLogger(__name__) -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class FacilityManagementView(TemplateView): template_name = "facility_management.html" diff --git a/kolibri/plugins/learn/views.py b/kolibri/plugins/learn/views.py index 83455496aaf..d8dda49e6e5 100644 --- a/kolibri/plugins/learn/views.py +++ b/kolibri/plugins/learn/views.py @@ -1,14 +1,13 @@ -from django.utils.decorators import method_decorator from django.views.generic.base import TemplateView from kolibri.core.decorators import cache_no_user_data -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class LearnView(TemplateView): template_name = "learn/learn.html" -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class MyDownloadsView(TemplateView): template_name = "learn/my_downloads.html" diff --git a/kolibri/plugins/policies/views.py b/kolibri/plugins/policies/views.py index 42643be40f8..30a591f6e47 100644 --- a/kolibri/plugins/policies/views.py +++ b/kolibri/plugins/policies/views.py @@ -1,9 +1,8 @@ -from django.utils.decorators import method_decorator from django.views.generic.base import TemplateView from kolibri.core.decorators import cache_no_user_data -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class PoliciesView(TemplateView): template_name = "policies/policies.html" diff --git a/kolibri/plugins/pwa/views.py b/kolibri/plugins/pwa/views.py index 5472c04a863..17ee4c1a381 100644 --- a/kolibri/plugins/pwa/views.py +++ b/kolibri/plugins/pwa/views.py @@ -4,9 +4,6 @@ # SPDX-License-Identifier: MIT from urllib.parse import quote -from django.utils.decorators import method_decorator -from django.views.decorators.cache import cache_page -from django.views.decorators.gzip import gzip_page from django.views.generic.base import TemplateView import kolibri @@ -15,8 +12,7 @@ from kolibri.utils.conf import OPTIONS -@method_decorator(gzip_page, name="dispatch") -@method_decorator(cache_page(60 * 60 * 24 * 7), name="dispatch") +@cache_no_user_data class PwaManifestView(TemplateView): template_name = "pwa/manifest.json" content_type = "application/manifest+json" @@ -99,9 +95,7 @@ def get_context_data(self, **kwargs): return context -@method_decorator(cache_no_user_data, name="dispatch") -@method_decorator(gzip_page, name="dispatch") -@method_decorator(cache_page(60 * 60 * 24 * 7), name="dispatch") +@cache_no_user_data class PwaServiceWorkerView(TemplateView): template_name = "pwa/sw.js" content_type = "application/javascript" diff --git a/kolibri/plugins/user_auth/frontend/app.js b/kolibri/plugins/user_auth/frontend/app.js index d9a9b755f47..64ee1438674 100644 --- a/kolibri/plugins/user_auth/frontend/app.js +++ b/kolibri/plugins/user_auth/frontend/app.js @@ -1,7 +1,9 @@ import { watch } from 'vue'; -import { useWindowFocus } from '@vueuse/core'; +import { get, useWindowFocus } from '@vueuse/core'; import router from 'kolibri/router'; import KolibriApp from 'kolibri-app'; +import useUser from 'kolibri/composables/useUser'; +import redirectBrowser from 'kolibri/utils/redirectBrowser'; import RootVue from './views/UserAuthIndex'; import routes from './routes'; import pluginModule from './modules/pluginModule'; @@ -26,6 +28,14 @@ class UserAuthModule extends KolibriApp { // `initialzeFlow` above `super.ready()` causes a delay, showing a white page. putting it after // causes the state not to be ready for components router.beforeEach(async (to, from, next) => { + // Authenticated users have no business on the auth pages; redirect them + // to their landing page. + const { isUserLoggedIn } = useUser(); + if (get(isUserLoggedIn)) { + redirectBrowser(); + return; + } + await initializeFlow(); next(); }); diff --git a/kolibri/plugins/user_auth/views.py b/kolibri/plugins/user_auth/views.py index d599628cd55..c760749aef6 100644 --- a/kolibri/plugins/user_auth/views.py +++ b/kolibri/plugins/user_auth/views.py @@ -1,15 +1,10 @@ from django.views.generic.base import TemplateView -from kolibri.core.views import RootURLRedirectView +from kolibri.core.decorators import cache_no_user_data +@cache_no_user_data class UserAuthView(TemplateView): - template_name = "user_auth/user_auth.html" + """Authenticated users are redirected away on the frontend, in app.js.""" - def get(self, request): - """ - When authenticated, redirect to the appropriate view - """ - if request.user.is_authenticated: - return RootURLRedirectView.as_view()(request) - return super().get(request) + template_name = "user_auth/user_auth.html" diff --git a/kolibri/plugins/user_profile/views.py b/kolibri/plugins/user_profile/views.py index a13ecda4e9c..c1f2d98232d 100644 --- a/kolibri/plugins/user_profile/views.py +++ b/kolibri/plugins/user_profile/views.py @@ -1,9 +1,8 @@ -from django.utils.decorators import method_decorator from django.views.generic.base import TemplateView from kolibri.core.decorators import cache_no_user_data -@method_decorator(cache_no_user_data, name="dispatch") +@cache_no_user_data class UserProfileView(TemplateView): template_name = "user_profile/user_profile.html" From 4616e6477d50d35631d61f97ddef653f8355f578 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Tue, 14 Jul 2026 16:23:30 -0700 Subject: [PATCH 2/2] Warm cached view bodies and re-warm on device language change - Decorating a view with cache_no_user_data registers it for warming. - A startup hook warms each registered view for the device language; other languages warm lazily on the cold-miss path. - The display language is baked into every cached shell, so a post-provision change to it re-warms them via a DeviceSettings post_save signal. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PeRsMyx4NSFFKrWpD4sZ2o --- kolibri/core/decorators.py | 49 ++++++++++- kolibri/core/device/signals.py | 34 ++++++++ kolibri/core/device/tasks.py | 7 ++ .../core/device/test/test_device_settings.py | 38 ++++++++ kolibri/core/test/test_decorators.py | 86 +++++++++++++++++++ 5 files changed, 213 insertions(+), 1 deletion(-) diff --git a/kolibri/core/decorators.py b/kolibri/core/decorators.py index dd68609963a..f398de6121f 100644 --- a/kolibri/core/decorators.py +++ b/kolibri/core/decorators.py @@ -14,6 +14,9 @@ from django.core.handlers.wsgi import WSGIRequest from django.db import connections +from django.urls import get_resolver +from django.urls import reverse +from django.urls import URLResolver from django.utils import translation from django.utils.cache import add_never_cache_headers from django.utils.cache import get_conditional_response @@ -23,6 +26,8 @@ from rest_framework.views import APIView from kolibri import __version__ as kolibri_version +from kolibri.core.device.translation import get_device_language +from kolibri.core.device.translation import get_settings_language from kolibri.core.utils.cache import process_cache logger = logging.getLogger(__name__) @@ -511,7 +516,8 @@ def run(): def cache_no_user_data(view_class): """ View-class decorator: serve the view's body from a shared cache (see - ``_SpaBodyCache``). Must not be used on a view that renders user data. + ``_CachedBody``) and register it so the body is warmed in the device + language at startup. Must not be used on a view that renders user data. """ cache = _CachedBody(view_class) @@ -520,4 +526,45 @@ def dispatch(self, request, *args, **kwargs): return cache(self, request, *args, **kwargs) view_class.dispatch = dispatch + # Marks the class as opted in, for the resolver walk in _cached_view_targets. + view_class._cache_no_user_data = True return view_class + + +def _cached_view_targets(): + # (url name, view callback) for every URL that serves a registered cached view. + def walk(resolver, namespaces): + for pattern in resolver.url_patterns: + if isinstance(pattern, URLResolver): + nested = namespaces + if pattern.namespace: + nested = namespaces + (pattern.namespace,) + yield from walk(pattern, nested) + elif pattern.name: + yield ":".join(namespaces + (pattern.name,)), pattern.callback + + for name, callback in walk(get_resolver(), ()): + view_class = getattr(callback, "view_class", None) + if getattr(view_class, "_cache_no_user_data", False): + yield name, callback + + +def warm_cached_views(): + """ + Render and store every registered cached view for the device's configured + language, so the first real request to each is a hit. Runs once at startup. + + Device language only - warming every supported language would render + hundreds of bodies, stealing CPU from the startup request burst on + low-power targets. Other languages warm lazily on first request. + """ + language = get_device_language() or get_settings_language() + try: + with translation.override(language): + for name, callback in _cached_view_targets(): + try: + callback(_build_request(reverse(name))) + except Exception: + logger.warning("Failed to warm %s", name, exc_info=True) + finally: + connections.close_all() diff --git a/kolibri/core/device/signals.py b/kolibri/core/device/signals.py index 4755506a606..b59ca85c4d6 100644 --- a/kolibri/core/device/signals.py +++ b/kolibri/core/device/signals.py @@ -1,8 +1,10 @@ from django.db import transaction from django.db.models.signals import post_delete from django.db.models.signals import post_save +from django.db.models.signals import pre_save from django.dispatch import receiver +from .models import DeviceSettings from .models import SyncQueue from .models import UserSyncStatus @@ -31,3 +33,35 @@ def update_status_after_commit(): UserSyncStatus.update_status(instance.user_id) transaction.on_commit(update_status_after_commit) + + +@receiver(pre_save, sender=DeviceSettings) +def stash_previous_device_language(sender, instance=None, *args, **kwargs): + """ + Record the persisted display language before the save so the post_save + handler can tell whether this save changed it. + """ + instance._previous_language_id = ( + DeviceSettings.objects.filter(pk=instance.pk) + .values_list("language_id", flat=True) + .first() + ) + + +@receiver(post_save, sender=DeviceSettings) +def warm_cached_views_on_language_change(sender, instance=None, *args, **kwargs): + """ + The display language is baked into every cached SPA shell, so re-warm them + when it is reconfigured. Only on a genuine change to a non-empty language: + the initial provisioning set (no previous language) is warmed at startup. + """ + previous_language_id = getattr(instance, "_previous_language_id", None) + if ( + previous_language_id + and instance.language_id + and instance.language_id != previous_language_id + ): + # Inline import: kolibri.core.device.tasks imports from this app. + from kolibri.core.device.tasks import warm_cached_views + + transaction.on_commit(warm_cached_views.enqueue_if_not) diff --git a/kolibri/core/device/tasks.py b/kolibri/core/device/tasks.py index 34094a57008..b366851c882 100644 --- a/kolibri/core/device/tasks.py +++ b/kolibri/core/device/tasks.py @@ -11,6 +11,7 @@ from kolibri.core.auth.models import FacilityUser from kolibri.core.auth.utils.deprovision import deprovision from kolibri.core.auth.viewsets.facility import FacilitySerializer +from kolibri.core.decorators import warm_cached_views as warm_cached_view_bodies from kolibri.core.device.hooks import GetOSUserHook from kolibri.core.device.models import DevicePermissions from kolibri.core.device.models import OSUser @@ -23,6 +24,7 @@ from kolibri.core.tasks.decorators import register_task from kolibri.core.tasks.permissions import FirstProvisioning from kolibri.core.tasks.permissions import IsDeviceUnusable +from kolibri.core.tasks.schedules import Enqueue from kolibri.core.tasks.utils import get_current_job from kolibri.core.tasks.validation import JobValidator from kolibri.core.utils.token_generator import TokenGenerator @@ -33,6 +35,11 @@ DEPROVISION_TASK_QUEUE = "device_deprovision" +@register_task(job_id="warm_cached_views", schedule=Enqueue()) +def warm_cached_views(): + warm_cached_view_bodies() + + class DeviceProvisionValidator(DeviceSerializerMixin, JobValidator): facility = FacilitySerializer(required=False, allow_null=True) facility_id = serializers.CharField(max_length=50, required=False, allow_null=True) diff --git a/kolibri/core/device/test/test_device_settings.py b/kolibri/core/device/test/test_device_settings.py index be9d457d658..4631f4b9092 100644 --- a/kolibri/core/device/test/test_device_settings.py +++ b/kolibri/core/device/test/test_device_settings.py @@ -1,6 +1,7 @@ import pytest from django.core.exceptions import ValidationError from django.test import TestCase +from mock import patch from kolibri.core.device.models import DeviceSettings from kolibri.core.device.models import get_device_hostname @@ -41,6 +42,43 @@ def test_delete_setting_manager(self): with self.assertRaises(DeviceSettings.DoesNotExist): DeviceSettings.objects.get() + @patch("kolibri.core.device.tasks.warm_cached_views.enqueue_if_not") + def test_language_change_warms_cached_views(self, mock_enqueue): + ds = DeviceSettings.objects.create(language_id="en") + + with self.captureOnCommitCallbacks(execute=True): + ds.language_id = "fr" + ds.save() + + mock_enqueue.assert_called_once() + + @patch("kolibri.core.device.tasks.warm_cached_views.enqueue_if_not") + def test_unchanged_language_does_not_warm_cached_views(self, mock_enqueue): + ds = DeviceSettings.objects.create(language_id="en") + + with self.captureOnCommitCallbacks(execute=True): + ds.language_id = "en" + ds.save() + + mock_enqueue.assert_not_called() + + @patch("kolibri.core.device.tasks.warm_cached_views.enqueue_if_not") + def test_language_cleared_does_not_warm_cached_views(self, mock_enqueue): + ds = DeviceSettings.objects.create(language_id="en") + + with self.captureOnCommitCallbacks(execute=True): + ds.language_id = None + ds.save() + + mock_enqueue.assert_not_called() + + @patch("kolibri.core.device.tasks.warm_cached_views.enqueue_if_not") + def test_initial_language_does_not_warm_cached_views(self, mock_enqueue): + with self.captureOnCommitCallbacks(execute=True): + DeviceSettings.objects.create(language_id="en") + + mock_enqueue.assert_not_called() + @pytest.mark.skip( reason="Other tests enabling the App plugin are not properly isolated" ) diff --git a/kolibri/core/test/test_decorators.py b/kolibri/core/test/test_decorators.py index 109c10af130..8aecddd97be 100644 --- a/kolibri/core/test/test_decorators.py +++ b/kolibri/core/test/test_decorators.py @@ -6,14 +6,23 @@ from django.template.response import TemplateResponse from django.test import RequestFactory from django.test import SimpleTestCase +from django.test import TestCase +from django.urls import reverse +from django.utils import translation from django.views.generic.base import View +from kolibri.core.auth.test.helpers import provision_device +from kolibri.core.decorators import _cached_view_targets from kolibri.core.decorators import _CachedBody from kolibri.core.decorators import BODY_CACHE_REFRESH from kolibri.core.decorators import cache_no_user_data from kolibri.core.decorators import InvalidQueryParamsException from kolibri.core.decorators import ParamValidator +from kolibri.core.decorators import warm_cached_views from kolibri.core.utils.cache import process_cache +from kolibri.plugins.user_auth.views import UserAuthView + +USER_AUTH_VIEW_NAME = "kolibri:kolibri.plugins.user_auth:user_auth" def run_inline(target): @@ -191,3 +200,80 @@ def test_conditional_get_matches_only_the_encoding_the_client_holds(self): # A gzip client holding the plain ETag must get the body, not a 304. self.assertEqual(crossed.status_code, 200) self.assertEqual(gzip.decompress(crossed.content), b"body-1") + + +class CachedViewTargetsTestCase(TestCase): + @classmethod + def setUpTestData(cls): + provision_device() + + def test_discovers_registered_views_from_the_urlconf(self): + targets = dict(_cached_view_targets()) + + # Decorated views across plugins must be discoverable for warming. + for name in ( + USER_AUTH_VIEW_NAME, + "kolibri:kolibri.plugins.learn:learn", + "kolibri:kolibri.plugins.device:device_management", + ): + self.assertIn(name, targets) + self.assertIs(targets[USER_AUTH_VIEW_NAME].view_class, UserAuthView) + + def test_every_discovered_name_is_reversible(self): + # Bad namespace assembly would make reverse() raise and silently skip the + # view at warm time. + with translation.override("en"): + for name, _callback in _cached_view_targets(): + reverse(name) + + +class WarmCachedViewsTestCase(TestCase): + @classmethod + def setUpTestData(cls): + provision_device() + + def setUp(self): + process_cache.clear() + + def test_warms_each_view_once_in_the_device_language(self): + calls = [] + + def callback(request): + calls.append((request.path, translation.get_language())) + return HttpResponse("shell") + + def fake_reverse(name): + return "/{}/{}/".format(translation.get_language(), name) + + with mock.patch( + "kolibri.core.decorators._cached_view_targets", + return_value=[("learn", callback), ("auth", callback)], + ), mock.patch("kolibri.core.decorators.reverse", fake_reverse), mock.patch( + "kolibri.core.decorators.get_device_language", return_value="es-es" + ), mock.patch("kolibri.core.decorators.connections"): + warm_cached_views() + + # Each cached view is warmed exactly once, in the device language. + # Other languages are not warmed at startup; they load lazily. + self.assertEqual(calls, [("/es-es/learn/", "es-es"), ("/es-es/auth/", "es-es")]) + + def test_falls_back_to_settings_language_when_device_language_missing(self): + calls = [] + + def callback(request): + calls.append(translation.get_language()) + return HttpResponse("shell") + + with mock.patch( + "kolibri.core.decorators._cached_view_targets", + return_value=[("learn", callback)], + ), mock.patch( + "kolibri.core.decorators.reverse", return_value="/x/" + ), mock.patch( + "kolibri.core.decorators.get_device_language", return_value=None + ), mock.patch( + "kolibri.core.decorators.get_settings_language", return_value="ar" + ), mock.patch("kolibri.core.decorators.connections"): + warm_cached_views() + + self.assertEqual(calls, ["ar"])