From 630d165c2203a1664b4151a96a7fdf3d8a14c4d1 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 27 Jan 2025 14:45:11 +1100 Subject: [PATCH] [Enhancement] Request cache (#8956) * Middleware for caching against request * Create helpers for setting / getting session cache * Settings objects check session cache first * Ensure setting is removed from session cache when updated * Cleaner implementation * Fix cache cleanup - ONLY allow access if there is a request object - Ensure cache is deleted once session is over * Skip plugin registry reload check --- src/backend/InvenTree/InvenTree/cache.py | 55 ++++++++++++++++++- src/backend/InvenTree/InvenTree/middleware.py | 22 ++++++++ src/backend/InvenTree/InvenTree/settings.py | 1 + src/backend/InvenTree/common/models.py | 53 ++++++++++++------ src/backend/InvenTree/plugin/registry.py | 7 +++ 5 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/cache.py b/src/backend/InvenTree/InvenTree/cache.py index 9510c29a01..a217c94fe1 100644 --- a/src/backend/InvenTree/InvenTree/cache.py +++ b/src/backend/InvenTree/InvenTree/cache.py @@ -1,6 +1,7 @@ """Configuration options for InvenTree external cache.""" import socket +import threading import structlog @@ -9,9 +10,18 @@ import InvenTree.ready logger = structlog.get_logger('inventree') +# Thread-local cache for caching data against the request object +thread_data = threading.local() + def cache_setting(name, default=None, **kwargs): - """Return a cache setting.""" + """Return the value of a particular cache setting. + + Arguments: + name: The name of the cache setting + default: The default value to return if the setting is not found + kwargs: Additional arguments to pass to the cache setting request + """ return InvenTree.config.get_setting( f'INVENTREE_CACHE_{name.upper()}', f'cache.{name.lower()}', default, **kwargs ) @@ -22,12 +32,12 @@ def cache_host(): return cache_setting('host', None) -def cache_port(): +def cache_port() -> int: """Return the cache port.""" return cache_setting('port', '6379', typecast=int) -def is_global_cache_enabled(): +def is_global_cache_enabled() -> bool: """Check if the global cache is enabled. - Test if the user has enabled and configured global cache @@ -100,3 +110,42 @@ def get_cache_config(global_cache: bool) -> dict: # Default: Use django local memory cache return {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'} + + +def create_session_cache(request) -> None: + """Create an empty session cache.""" + thread_data.request = request + thread_data.request_cache = {} + + +def delete_session_cache() -> None: + """Remove the session cache once the request is complete.""" + if hasattr(thread_data, 'request'): + del thread_data.request + + if hasattr(thread_data, 'request_cache'): + del thread_data.request_cache + + +def get_session_cache(key: str) -> any: + """Return a cached value from the session cache.""" + # Only return a cached value if the request object is available too + if not hasattr(thread_data, 'request'): + return None + + request_cache = getattr(thread_data, 'request_cache', None) + if request_cache is not None: + val = request_cache.get(key, None) + return val + + +def set_session_cache(key: str, value: any) -> None: + """Set a cached value in the session cache.""" + # Only set a cached value if the request object is available too + if not hasattr(thread_data, 'request'): + return + + request_cache = getattr(thread_data, 'request_cache', None) + + if request_cache is not None: + request_cache[key] = value diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index bf6cdac365..f7938ab049 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -7,12 +7,14 @@ from django.contrib.auth.middleware import PersistentRemoteUserMiddleware from django.http import HttpResponse from django.shortcuts import redirect from django.urls import Resolver404, include, path, resolve, reverse_lazy +from django.utils.deprecation import MiddlewareMixin import structlog from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware from error_report.middleware import ExceptionProcessor from common.settings import get_global_setting +from InvenTree.cache import create_session_cache, delete_session_cache from InvenTree.urls import frontendpatterns from users.models import ApiToken @@ -215,3 +217,23 @@ class InvenTreeExceptionProcessor(ExceptionProcessor): ) error.save() + + +class InvenTreeRequestCacheMiddleware(MiddlewareMixin): + """Middleware to perform caching against the request object. + + This middleware is used to cache data against the request object, + which can be used to store data for the duration of the request. + + In this fashion, we can avoid hitting the external cache multiple times, + much less the database! + """ + + def process_request(self, request): + """Create a request-specific cache object.""" + create_session_cache(request) + + def process_response(self, request, response): + """Clear the cache object.""" + delete_session_cache() + return response diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index a591c4948b..9e962f5d30 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -330,6 +330,7 @@ MIDDLEWARE = CONFIG.get( 'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA 'maintenance_mode.middleware.MaintenanceModeMiddleware', 'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting + 'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching 'django_structlog.middlewares.RequestMiddleware', # Structured logging ], ) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index c46642a4b8..7bcbfa202a 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -52,6 +52,7 @@ import users.models from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType from generic.states import ColorEnum from generic.states.custom import state_color_mappings +from InvenTree.cache import get_session_cache, set_session_cache from InvenTree.sanitizer import sanitize_svg logger = structlog.get_logger('inventree') @@ -156,6 +157,9 @@ class BaseInvenTreeSetting(models.Model): if do_cache: self.save_to_cache() + # Remove the setting from the request cache + set_session_cache(self.cache_key, None) + # Execute after_save action self._call_settings_function('after_save', args, kwargs) @@ -485,22 +489,20 @@ class BaseInvenTreeSetting(models.Model): - Key is case-insensitive - Returns None if no match is made - First checks the cache to see if this object has recently been accessed, - and returns the cached version if so. + As settings are accessed frequently, this function will attempt to access the cache first: + + 1. Check the ephemeral request cache + 2. Check the global cache + 3. Query the database """ key = str(key).strip().upper() # Unless otherwise specified, attempt to create the setting create = kwargs.pop('create', True) - # Specify if cache lookup should be performed - do_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED) - - filters = { - 'key__iexact': key, - # Optionally filter by other keys - **cls.get_filters(**kwargs), - } + # Specify if global cache lookup should be performed + # If not specified, determine based on whether global cache is enabled + access_global_cache = kwargs.pop('cache', django_settings.GLOBAL_CACHE_ENABLED) # Prevent saving to the database during certain operations if ( @@ -510,21 +512,36 @@ class BaseInvenTreeSetting(models.Model): or InvenTree.ready.isRunningBackup() ): create = False - do_cache = False + access_global_cache = False cache_key = cls.create_cache_key(key, **kwargs) - if do_cache: + # Fist, attempt to pull the setting from the request cache + if setting := get_session_cache(cache_key): + return setting + + if access_global_cache: try: # First attempt to find the setting object in the cache cached_setting = cache.get(cache_key) if cached_setting is not None: + # Store the cached setting into the session cache + + set_session_cache(cache_key, cached_setting) return cached_setting except Exception: # Cache is not ready yet - do_cache = False + access_global_cache = False + + # At this point, we need to query the database + + filters = { + 'key__iexact': key, + # Optionally filter by other keys + **cls.get_filters(**kwargs), + } try: settings = cls.objects.all() @@ -551,9 +568,13 @@ class BaseInvenTreeSetting(models.Model): # The setting failed validation - might be due to duplicate keys pass - if setting and do_cache: - # Cache this setting object - setting.save_to_cache() + if setting: + # Cache this setting object to the request cache + set_session_cache(cache_key, setting) + + if access_global_cache: + # Cache this setting object to the global cache + setting.save_to_cache() return setting diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index f6d0b17b23..6ee8f92c47 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -26,6 +26,7 @@ from django.utils.translation import gettext_lazy as _ import structlog +import InvenTree.cache from common.settings import get_global_setting, set_global_setting from InvenTree.config import get_plugin_dir from InvenTree.ready import canAppAccessDatabase @@ -836,6 +837,12 @@ class PluginsRegistry: # Skip check if database cannot be accessed return + if InvenTree.cache.get_session_cache('plugin_registry_checked'): + # Return early if the registry has already been checked (for this request) + return + + InvenTree.cache.set_session_cache('plugin_registry_checked', True) + logger.debug('Checking plugin registry hash') # If not already cached, calculate the hash