# Implementing a Per-Request Cache Decorator in Django Our goal is to implement a `per_request_cache` decorator in Django that can cache function results on a per-request basis. Here's what we want to achieve: ```python @per_request_cache def expensive_calculation(param): # Some expensive operation return result ``` This decorator should cache the result of `expensive_calculation` for each unique parameter, but only for the duration of the current request. ## The Challenge Implementing this decorator presents several challenges: 1. How to associate cache data with specific requests 2. How to access the current request within a decorator 3. How to handle concurrent requests 4. How to manage cache data in multi-threaded environments ## Initial Implementation Let's start with a basic implementation of our `per_request_cache` decorator: ```python caches = {} class per_request_cache: def __init__(self, fn): self._fn = fn @property def _cache(self): return caches[current_request].setdefault(self, {}) def __call__(self, arg): if arg not in self._cache: self._cache[arg] = self._fn(arg) return self._cache[arg] ``` This implementation looks promising, but it relies on a `current_request` variable that we haven't defined yet. ## The Global Variable Pitfall One might consider storing the current request in a global variable: ```python current_request = None class RequestMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): global current_request current_request = request response = self.get_response(request) return response ``` However, this approach fails when dealing with concurrent requests. If request 2 is invoked before request 1 finishes, any subsequent calculations for request 1 will incorrectly use request 2's context. ## Thread-Local Storage: A Step Forward To handle concurrent requests, we can use thread-local storage: ```python import threading _local = threading.local() class RequestMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): _local.request = request response = self.get_response(request) return response ``` Thread-local allows each thread to have its own isolated storage. Here's how it works: 1. An empty dictionary is initialized when a `threading.local()` object is created. 2. The current thread's ID is used as a key to fetch a thread-specific dictionary whenever you access an attribute on this object (such as `_local.request`). 3. Using the attribute name in this thread-specific dictionary, it retrieves or sets the object attribute. This means that even if multiple threads are accessing `_local.request` simultaneously, they're actually accessing `request` objects from different dictionaries, each specific to their own thread. This isolation prevents data races and ensures that each thread (and thus each request) has its own separate storage. Now we can update our `per_request_cache` to use `_local.request`: ```python @property def _cache(self): return caches[_local.request].setdefault(self, {}) ``` ## Managing Cache Lifecycle We need to ensure that the cache is cleared after each request. We can do this with a middleware: ```python class ClearCacheMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): _local.request = request caches[request] = {} response = self.get_response(request) caches.pop(request) return response ``` ## The Multi-Threading Dilemma Thread-local storage solves our concurrency issue but introduces a new problem. If we create new threads within a request to perform parallel tasks, these new threads won't have access to the current request. ```python from concurrent.futures import ThreadPoolExecutor @per_request_cache def expensive_calculation(arg): # Some expensive operation return result def multiple_expensive_calculation(args): with ThreadPoolExecutor() as executor: results = list(executor.map(expensive_calculation, args)) return results ``` Here, `ThreadPoolExecutor` creates new threads to execute tasks concurrently. However, these worker threads don't inherit the thread-local data from the parent thread, leaving them without access to the current request. ## The Solution: Thread Initialization To solve this, we need to pass the current request to each new thread we create: ```python def set_request(request): _local.request = request def get_thread_pool(max_workers=None, thread_name_prefix=""): return ThreadPoolExecutor( max_workers=max_workers, thread_name_prefix=thread_name_prefix, initializer=set_request, initargs=(_local.request,) ) ``` The `ThreadPoolExecutor` constructor's `initializer` and `initargs` parameters are crucial for our solution: - `initializer`: a function executed in each new worker thread. - `initargs`: arguments for the initializer, prepared in the current thread. This allows data passage between threads: - `set_request` is our `initializer` that sets `_local.request` in new threads. - `(_local.request,)` is our `initargs`. It captures the current request from the original thread, which will be passed to `set_request`. This ensures all executor threads access the correct request, even though they're running in different threads from the original request. Now, instead of creating a `ThreadPoolExecutor` directly, we use our `get_thread_pool` function: ```python def multiple_expensive_calculation(args): with get_thread_pool() as executor: results = list(executor.map(expensive_calculation, args)) return results ``` This ensures that all threads created by the executor have access to the current request, solving our multi-threading dilemma. ## Putting It All Together Here's our complete implementation of a per-request cache decorator: ```python from __future__ import annotations import threading from collections.abc import Callable, Hashable from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from typing import Annotated, Any, Generic, ParamSpec, TypeVar from django.http import HttpRequest from django.http.response import HttpResponse CacheContext = Annotated[Hashable, "CacheContext"] CacheScope = Annotated[Hashable, "CacheScope"] CacheKey = Annotated[Hashable, "CacheKey"] P = ParamSpec("P") R = TypeVar("R") NO_CACHE = object() class _ThreadLocal(threading.local): ctx: CacheContext = NO_CACHE _local = _ThreadLocal() caches: dict[CacheContext, dict[CacheScope, dict[CacheKey, Any]]] = {} @contextmanager def cache_context(ctx: CacheContext) -> Any: _local.ctx = ctx caches[ctx] = {} yield caches.pop(ctx) _local.ctx = NO_CACHE class ClearCacheMiddleware: """Clears local cache after each request""" def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: with cache_context(request): return self.get_response(request) class per_reqeust_cache(Generic[P, R]): def __init__(self, fn: Callable[P, R]) -> None: self._fn = fn def _cache(self) -> dict[Any, Any]: if _local.ctx is NO_CACHE: return {} return caches[_local.ctx].setdefault(self, {}) def __call__(self, *args: P.args, **kwds: P.kwargs) -> R: key = self.hash(*args, **kwds) cache = self._cache() if key not in cache: cache[key] = self._fn(*args, **kwds) return cache[key] def invalidate(self, *args: P.args, **kwds: P.kwargs) -> None: self._cache().pop(self.hash(*args, **kwds), None) if hasattr(self._fn, "invalidate"): self._fn.invalidate(*args, **kwds) def cache_clear(self) -> None: self._cache().clear() @staticmethod def hash(*args: P.args, **kwds: P.kwargs) -> tuple[Any, ...]: return sum(sorted(kwds.items()), args) def set_ctx(ctx: CacheContext) -> None: _local.ctx = ctx def get_thread_pool( max_workers: int | None = None, thread_name_prefix: str = "" ) -> ThreadPoolExecutor: return ThreadPoolExecutor( max_workers, thread_name_prefix, initializer=set_ctx, initargs=(_local.ctx,), ) ``` ## Conclusion Implementing a per-request cache decorator in Django requires careful consideration of concurrency and thread management. By using thread-local storage and proper thread initialization, we've created a solution that works across multiple threads while maintaining request isolation.