Source code for utils.cached_response

"""In-process memoization of pre-rendered JSON responses.

``GET`` endpoints that serialize large, instance-static payloads (e.g.
the genomic scores registry with all of its histograms) are expensive
to re-render on every request even though the result only changes when
the GPF instance is reloaded.

This module memoizes the *rendered JSON bytes* keyed by the instance id
and the instance-load timestamp, so the serialization runs once per
instance load.  The helper is designed to compose under the existing
``etag(get_instance_timestamp_etag)`` decorator: that decorator handles
conditional ``304`` short-circuiting before the view body runs, so the
build function here only ever executes for a ``200``.
"""
from __future__ import annotations

from collections.abc import Callable
from threading import Lock
from typing import Any

from django.http import HttpResponse
from gpf_instance.gpf_instance import get_instance_timestamp
from rest_framework.renderers import JSONRenderer

CACHE_CONTROL = "public, max-age=3600"

# The cache is shared across views (the "all" entries plus one entry per
# distinct ``score_descs/<score_id>`` request).  A small cap of 2 would let
# the per-score_id keys evict the two hot "all" entries and cause re-render
# thrash, so the bound is generous; each entry is a tiny ``bytes`` blob, so
# 16 entries is negligible memory.  Eviction is insertion-order (FIFO):
# evict the oldest-inserted key when the cap is exceeded.
_MAX_ENTRIES = 16
_CACHE: dict[tuple[Any, ...], bytes] = {}
_CACHE_LOCK = Lock()


def _get_or_render(
    key: tuple[Any, ...], build: Callable[[], Any],
) -> bytes:
    """Return memoized JSON bytes for ``key``, rendering on a miss.

    ``build`` returns the plain-Python payload; it is serialized with
    DRF's :class:`JSONRenderer` so the bytes are byte-identical to what
    a DRF ``Response`` would have produced (numpy scalars included).

    The cache is bounded to ``_MAX_ENTRIES`` with simple
    insertion-order eviction; the key already includes the instance
    timestamp, so a reload naturally produces fresh entries.
    """
    with _CACHE_LOCK:
        cached = _CACHE.get(key)
        if cached is not None:
            return cached

    # Render outside the lock so an expensive serialization never blocks
    # concurrent requests.  Deliberate trade-off: concurrent cold misses on
    # the same key may each render once (the last writer wins on insert);
    # the rendered bytes are byte-identical, so callers are unaffected.
    body: bytes = JSONRenderer().render(build())

    with _CACHE_LOCK:
        _CACHE[key] = body
        while len(_CACHE) > _MAX_ENTRIES:
            oldest = next(iter(_CACHE))
            del _CACHE[oldest]
    return body


[docs] def cached_json_response( instance_id: str, build: Callable[[], Any], *extra: Any, ) -> HttpResponse: """Return a cached ``application/json`` response. The rendered body is memoized in-process keyed by ``(instance_id, get_instance_timestamp(), extra)``. Including ``instance_id`` prevents cross-fixture leakage in tests; ``extra`` carries any view-varying parameter (e.g. an optional path segment). The response carries ``Cache-Control: public, max-age=3600``. """ key = (instance_id, get_instance_timestamp(), *extra) body = _get_or_render(key, build) response = HttpResponse(body, content_type="application/json") response["Cache-Control"] = CACHE_CONTROL return response