Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 247 additions & 49 deletions kolibri/core/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,35 @@
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.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
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.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__)

TRUE_VALUES = ("1", "true")
FALSE_VALUES = ("0", "false")
Expand Down Expand Up @@ -311,62 +330,241 @@ 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 __init__(self, view_class):
self._view_class = view_class
self._dispatch = view_class.dispatch

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The refresh path elects a single refresher via process_cache.add(lock_key, ...), but the cold-miss branch renders inline with no such guard. N concurrent first requests for an unwarmed path (any non-device language, or the window before the startup warm completes) each render the shell synchronously — the thundering-herd case this PR targets. Not a regression (the old decorator had no lock either), but given the cold-start goal, worth gating cold rendering behind the same election or noting it as a follow-up.

return self._conditional(
request, self._render_and_store(view, request, args, kwargs, body_key)
)

@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
)

@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

def render_and_cache(response, cache_key):
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.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
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: timeout=None (intentional, so a stale copy is always servable) means entries never expire. The key embeds kolibri_version and the language-prefixed path, so across version upgrades and each distinct language served, entries accumulate permanently. Fine for a locmem process_cache (clears on restart), but a Redis-backed process_cache without an eviction policy would retain old-version/rare-language bodies indefinitely. Consider a generous hard TTL (hours) while still keying refresh off refresh_at, so orphaned entries age out while live paths stay warm.

return self._pick(request, variants)

def _refresh_in_background(self, request, body_key, lock_key):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: _refresh_in_background renders a full Django view on a bare daemon thread, outside the task framework that AGENTS.md/backend docs steer async work through (observable, retryable, resource-governed). There are reasonable arguments for the escape hatch (a 15s refresh shouldn't queue behind real jobs), and it is bounded — single-flight election, DB connections closed in finally, translation.override, synthetic request. Surfacing as a design-tension: is an in-process thread the intended escape hatch, and any concern about rendering with middleware not run on this path? The del request.session + _build_request seam makes the no-user-data contract explicit, which mitigates most of it.

# 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
``_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)

def calculate_spa_etag(*args, **kwargs):
# Clear the local thread 'response' property
setattr(_response, "response", None)
@functools.wraps(view_class.dispatch)
def dispatch(self, request, *args, **kwargs):
return cache(self, request, *args, **kwargs)

request = args[0]
etag = cache.get(CACHE_KEY_TEMPLATE.format(request.path))
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

# Doing this here - will also be the same in inner_func
# required to delete the session for this to work as expected
del request.session

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
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

@etag(calculate_spa_etag)
def inner_func(*args, **kwargs):
request = args[0]
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

response = getattr(_response, "response", None)
if not response:
response = view_func(*args, **kwargs)

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
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.

return inner_func
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()
34 changes: 34 additions & 0 deletions kolibri/core/device/signals.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Loading
Loading