From 20477fbfcc306a28d97546dec4a399bb9a2622f2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Jul 2025 18:54:26 +1000 Subject: [PATCH] Plugin sequence fix (#10057) * Fix error message * Add registry ready flag * Add is_ready func * Cleaner init code * Protect against plugin access until ready * Add decorator for registry entrypoint funcs * Tweak return value * Only add plugin URLs if registry is ready * Fix load logic * Refactor @registry_entrypoint decorator * Tweak * Typing * Reimplement is_ready property --- src/backend/InvenTree/InvenTree/apps.py | 64 ++----------------- src/backend/InvenTree/common/currency.py | 6 +- src/backend/InvenTree/plugin/apps.py | 22 ++----- src/backend/InvenTree/plugin/registry.py | 80 ++++++++++++++++++++---- src/backend/InvenTree/plugin/urls.py | 44 +++++++------ 5 files changed, 105 insertions(+), 111 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index 0e0c8d308a..4abe5a6b30 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -9,7 +9,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import AppRegistryNotReady from django.db import transaction -from django.db.utils import IntegrityError, OperationalError +from django.db.utils import IntegrityError import structlog from allauth.socialaccount.signals import social_account_updated @@ -65,7 +65,8 @@ class InvenTreeConfig(AppConfig): self.start_background_tasks() if not InvenTree.ready.isInTestMode(): # pragma: no cover - self.update_exchange_rates() + # Update exchange rates + InvenTree.tasks.offload_task(InvenTree.tasks.update_exchange_rates) # Let the background worker check for migrations InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations) @@ -179,6 +180,8 @@ class InvenTreeConfig(AppConfig): InvenTree.tasks.offload_task( InvenTree.tasks.heartbeat, force_async=True, group='heartbeat' ) + except AppRegistryNotReady: # pragma: no cover + pass except Exception: pass @@ -194,63 +197,6 @@ class InvenTreeConfig(AppConfig): except Exception as e: # pragma: no cover logger.exception('Error loading tasks for %s: %s', app_name, e) - def update_exchange_rates(self): # pragma: no cover - """Update exchange rates each time the server is started. - - Only runs *if*: - a) Have not been updated recently (one day or less) - b) The base exchange rate has been altered - """ - try: - from djmoney.contrib.exchange.models import ExchangeBackend - - from common.currency import currency_code_default - from InvenTree.tasks import update_exchange_rates - except AppRegistryNotReady: # pragma: no cover - pass - - base_currency = currency_code_default() - - update = False - - try: - backend = ExchangeBackend.objects.filter(name='InvenTreeExchange') - - if backend.exists(): - backend = backend.first() - - last_update = backend.last_update - - if last_update is None: - # Never been updated - logger.info('Exchange backend has never been updated') - update = True - - # Backend currency has changed? - if base_currency != backend.base_currency: - logger.info( - 'Base currency changed from %s to %s', - backend.base_currency, - base_currency, - ) - update = True - - except ExchangeBackend.DoesNotExist: - logger.info('Exchange backend not found - updating') - update = True - - except Exception: - # Some other error - potentially the tables are not ready yet - return - - if update: - try: - update_exchange_rates() - except OperationalError: - logger.warning('Could not update exchange rates - database not ready') - except Exception as e: - logger.exception('Error updating exchange rates: %s (%s)', e, type(e)) - def update_site_url(self): """Update the site URL setting. diff --git a/src/backend/InvenTree/common/currency.py b/src/backend/InvenTree/common/currency.py index 8c501b36b2..1fed388ffe 100644 --- a/src/backend/InvenTree/common/currency.py +++ b/src/backend/InvenTree/common/currency.py @@ -15,12 +15,14 @@ import InvenTree.helpers logger = structlog.get_logger('inventree') -def currency_code_default(): +def currency_code_default(create: bool = True): """Returns the default currency code (or USD if not specified).""" from common.settings import get_global_setting try: - code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True) + code = get_global_setting( + 'INVENTREE_DEFAULT_CURRENCY', create=create, cache=True + ) except Exception: # pragma: no cover # Database may not yet be ready, no need to throw an error here code = '' diff --git a/src/backend/InvenTree/plugin/apps.py b/src/backend/InvenTree/plugin/apps.py index 99f5f4ea01..cd2474bb9f 100644 --- a/src/backend/InvenTree/plugin/apps.py +++ b/src/backend/InvenTree/plugin/apps.py @@ -32,23 +32,11 @@ class PluginAppConfig(AppConfig): else: logger.info('Loading InvenTree plugins') - if not registry.is_loading: - # this is the first startup - try: - from common.models import InvenTreeSetting - - if InvenTreeSetting.get_setting( - 'PLUGIN_ON_STARTUP', create=False, cache=False - ): - # make sure all plugins are installed - registry.install_plugin_file() - except Exception: # pragma: no cover - pass - - # Perform a full reload of the plugin registry - registry.reload_plugins( - full_reload=True, force_reload=True, collect=True - ) + if not registry.is_ready: + # Mark the registry as ready + # This ensures that other apps cannot access the registry before it is fully initialized + logger.info('Plugin registry is ready - performing initial load') + registry.set_ready() # drop out of maintenance # makes sure we did not have an error in reloading and maintenance is still active diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index d664903ce8..ac8d816532 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -4,6 +4,7 @@ - Manages setup and teardown of plugin class instances """ +import functools import importlib import importlib.util import os @@ -43,6 +44,37 @@ from .plugin import InvenTreePlugin logger = structlog.get_logger('inventree') +def registry_entrypoint(check_reload: bool = True, default_value: Any = None) -> Any: + """Function decorator for registry entrypoints methods. + + - Ensure that the registry is ready before calling the method. + - Check if the registry needs to be reloaded. + """ + + def decorator(method): + """Decorator to ensure registry is ready before calling the method.""" + + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + """Wrapper function to ensure registry is ready.""" + if not self.ready: + logger.warning( + "Plugin registry is not ready - cannot call method '%s'", + method.__name__, + ) + return default_value + + # Check if the registry needs to be reloaded + if check_reload: + self.check_reload() + + return method(self, *args, **kwargs) + + return wrapper + + return decorator + + class PluginsRegistry: """The PluginsRegistry class.""" @@ -68,6 +100,8 @@ class PluginsRegistry: 'parameter-exporter', ] + ready: bool + def __init__(self) -> None: """Initialize registry. @@ -82,6 +116,8 @@ class PluginsRegistry: str, InvenTreePlugin ] = {} # List of all plugin instances + self.ready = False # Marks if the registry is ready to be used + # Keep an internal hash of the plugin registry state self.registry_hash = None @@ -100,11 +136,35 @@ class PluginsRegistry: self.installed_apps = [] # Holds all added plugin_paths + def set_ready(self): + """Set the registry as ready to be used. + + This method should only be called once per application start, + after all apps have been loaded and the registry is fully initialized. + """ + from common.models import InvenTreeSetting + + self.ready = True + + # Install plugins from file (if required) + if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): + # make sure all plugins are installed + registry.install_plugin_file() + + # Perform initial plugin discovery + self.reload_plugins(full_reload=True, force_reload=True, collect=True) + + @property + def is_ready(self) -> bool: + """Return True if the plugin registry is ready to be used.""" + return self.ready + @property def is_loading(self) -> bool: """Return True if the plugin registry is currently loading.""" return self.loading_lock.locked() + @registry_entrypoint() def get_plugin( self, slug: str, active: bool = True, with_mixin: Optional[str] = None ) -> InvenTreePlugin: @@ -118,9 +178,6 @@ class PluginsRegistry: Returns: InvenTreePlugin or None: The plugin instance if found, otherwise None. """ - # Check if the registry needs to be reloaded - self.check_reload() - if slug not in self.plugins: logger.warning("Plugin registry has no record of plugin '%s'", slug) return None @@ -169,6 +226,7 @@ class PluginsRegistry: return cfg + @registry_entrypoint() def set_plugin_state(self, slug: str, state: bool): """Set the state(active/inactive) of a plugin. @@ -176,9 +234,6 @@ class PluginsRegistry: slug (str): Plugin slug state (bool): Plugin state - true = active, false = inactive """ - # Check if the registry needs to be reloaded - self.check_reload() - if slug not in self.plugins_full: logger.warning("Plugin registry has no record of plugin '%s'", slug) return @@ -190,6 +245,7 @@ class PluginsRegistry: # Update the registry hash value self.update_plugin_hash() + @registry_entrypoint() def call_plugin_function(self, slug: str, func: str, *args, **kwargs): """Call a member function (named by 'func') of the plugin named by 'slug'. @@ -198,9 +254,6 @@ class PluginsRegistry: Instead, any error messages are returned to the worker. """ - # Check if the registry needs to be reloaded - self.check_reload() - raise_error = kwargs.pop('raise_error', True) plugin = self.get_plugin(slug) @@ -220,6 +273,8 @@ class PluginsRegistry: return plugin_func(*args, **kwargs) # region registry functions + + @registry_entrypoint(default_value=[]) def with_mixin( self, mixin: str, active: bool = True, builtin: Optional[bool] = None ) -> list[InvenTreePlugin]: @@ -230,9 +285,6 @@ class PluginsRegistry: active (bool, optional): Filter by 'active' status of plugin. Defaults to True. builtin (bool, optional): Filter by 'builtin' status of plugin. Defaults to None. """ - # Check if the registry needs to be loaded - self.check_reload() - mixin = str(mixin).lower().strip() result = [] @@ -304,6 +356,7 @@ class PluginsRegistry: logger.info('Finished unloading plugins') + @registry_entrypoint(check_reload=False) def reload_plugins( self, full_reload: bool = False, @@ -369,7 +422,7 @@ class PluginsRegistry: logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins)) except Exception as e: - logger.exception('Expected error during plugin reload: %s', e) + logger.exception('Unexpected error during plugin reload: %s', e) log_error('reload_plugins', scope='plugins') finally: @@ -900,6 +953,7 @@ class PluginsRegistry: return str(data.hexdigest()) + @registry_entrypoint(default_value=False, check_reload=False) def check_reload(self): """Determine if the registry needs to be reloaded. diff --git a/src/backend/InvenTree/plugin/urls.py b/src/backend/InvenTree/plugin/urls.py index 30852f4509..f120f47d17 100644 --- a/src/backend/InvenTree/plugin/urls.py +++ b/src/backend/InvenTree/plugin/urls.py @@ -18,28 +18,32 @@ def get_plugin_urls(): urls = [] - if get_global_setting('ENABLE_PLUGINS_URL', False) or settings.PLUGIN_TESTING_SETUP: - for plugin in registry.with_mixin(PluginMixinEnum.URLS): - try: - if plugin_urls := plugin.urlpatterns: - # Check if the plugin has a custom URL pattern - for url in plugin_urls: - # Attempt to resolve against the URL pattern as a validation check - try: - url.resolve('') - except Resolver404: - pass + if registry.is_ready: + if ( + get_global_setting('ENABLE_PLUGINS_URL', False) + or settings.PLUGIN_TESTING_SETUP + ): + for plugin in registry.with_mixin(PluginMixinEnum.URLS): + try: + if plugin_urls := plugin.urlpatterns: + # Check if the plugin has a custom URL pattern + for url in plugin_urls: + # Attempt to resolve against the URL pattern as a validation check + try: + url.resolve('') + except Resolver404: + pass - urls.append( - re_path( - f'^{plugin.slug}/', - include((plugin_urls, plugin.slug)), - name=plugin.slug, + urls.append( + re_path( + f'^{plugin.slug}/', + include((plugin_urls, plugin.slug)), + name=plugin.slug, + ) ) - ) - except Exception: - log_error('get_plugin_urls', plugin=plugin.slug) - continue + except Exception: + log_error('get_plugin_urls', plugin=plugin.slug) + continue # Redirect anything else to the root index urls.append(