Skip to content

Commit 06eb948

Browse files
Plugin reload mechanism (#5649)
* Plugin reload mechanism - Wrap reload_plugins with mutex lock - Add methods for calculating plugin registry hash * Perform plugin reload at critical entry points to the registry - Background worker will correctly reload registry before performing tasks - Ensures that the background worker plugin regsistry is up to date
1 parent 78905a4 commit 06eb948

5 files changed

Lines changed: 137 additions & 52 deletions

File tree

InvenTree/InvenTree/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ def get_secret_key():
319319
key = ''.join([random.choice(options) for i in range(100)])
320320
secret_key_file.write_text(key)
321321

322-
logger.info("Loading SECRET_KEY from '%s'", secret_key_file)
322+
logger.debug("Loading SECRET_KEY from '%s'", secret_key_file)
323323

324324
key_data = secret_key_file.read_text().strip()
325325

InvenTree/plugin/apps.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ def ready(self):
4242
except Exception: # pragma: no cover
4343
pass
4444

45-
# get plugins and init them
46-
registry.plugin_modules = registry.collect_plugins()
47-
registry.load_plugins()
45+
# Perform a full reload of the plugin registry
46+
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
4847

4948
# drop out of maintenance
5049
# makes sure we did not have an error in reloading and maintenance is still active

InvenTree/plugin/base/integration/ScheduleMixin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def __init__(self):
5757
@classmethod
5858
def _activate_mixin(cls, registry, plugins, *args, **kwargs):
5959
"""Activate scheudles from plugins with the ScheduleMixin."""
60-
logger.info('Activating plugin tasks')
60+
logger.debug('Activating plugin tasks')
6161

6262
from common.models import InvenTreeSetting
6363

InvenTree/plugin/base/integration/SettingsMixin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _activate_mixin(cls, registry, plugins, *args, **kwargs):
3737
Add all defined settings form the plugins to a unified dict in the registry.
3838
This dict is referenced by the PluginSettings for settings definitions.
3939
"""
40-
logger.info('Activating plugin settings')
40+
logger.debug('Activating plugin settings')
4141

4242
registry.mixins_settings = {}
4343

@@ -49,7 +49,7 @@ def _activate_mixin(cls, registry, plugins, *args, **kwargs):
4949
@classmethod
5050
def _deactivate_mixin(cls, registry, **kwargs):
5151
"""Deactivate all plugin settings."""
52-
logger.info('Deactivating plugin settings')
52+
logger.debug('Deactivating plugin settings')
5353
# clear settings cache
5454
registry.mixins_settings = {}
5555

InvenTree/plugin/registry.py

Lines changed: 131 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import os
1111
import time
1212
from pathlib import Path
13+
from threading import Lock
1314
from typing import Any, Dict, List, OrderedDict
1415

1516
from django.apps import apps
@@ -58,15 +59,25 @@ def __init__(self) -> None:
5859

5960
self.errors = {} # Holds discovering errors
6061

62+
self.loading_lock = Lock() # Lock to prevent multiple loading at the same time
63+
6164
# flags
62-
self.is_loading = False # Are plugins being loaded right now
6365
self.plugins_loaded = False # Marks if the registry fully loaded and all django apps are reloaded
6466
self.apps_loading = True # Marks if apps were reloaded yet
6567

6668
self.installed_apps = [] # Holds all added plugin_paths
6769

70+
@property
71+
def is_loading(self):
72+
"""Return True if the plugin registry is currently loading"""
73+
return self.loading_lock.locked()
74+
6875
def get_plugin(self, slug):
6976
"""Lookup plugin by slug (unique key)."""
77+
78+
# Check if the registry needs to be reloaded
79+
self.check_reload()
80+
7081
if slug not in self.plugins:
7182
logger.warning("Plugin registry has no record of plugin '%s'", slug)
7283
return None
@@ -80,6 +91,10 @@ def set_plugin_state(self, slug, state):
8091
slug (str): Plugin slug
8192
state (bool): Plugin state - true = active, false = inactive
8293
"""
94+
95+
# Check if the registry needs to be reloaded
96+
self.check_reload()
97+
8398
if slug not in self.plugins_full:
8499
logger.warning("Plugin registry has no record of plugin '%s'", slug)
85100
return
@@ -96,6 +111,10 @@ def call_plugin_function(self, slug, func, *args, **kwargs):
96111
97112
Instead, any error messages are returned to the worker.
98113
"""
114+
115+
# Check if the registry needs to be reloaded
116+
self.check_reload()
117+
99118
plugin = self.get_plugin(slug)
100119

101120
if not plugin:
@@ -105,9 +124,35 @@ def call_plugin_function(self, slug, func, *args, **kwargs):
105124

106125
return plugin_func(*args, **kwargs)
107126

108-
# region public functions
127+
# region registry functions
128+
def with_mixin(self, mixin: str, active=None, builtin=None):
129+
"""Returns reference to all plugins that have a specified mixin enabled."""
130+
131+
# Check if the registry needs to be loaded
132+
self.check_reload()
133+
134+
result = []
135+
136+
for plugin in self.plugins.values():
137+
if plugin.mixin_enabled(mixin):
138+
139+
if active is not None:
140+
# Filter by 'active' status of plugin
141+
if active != plugin.is_active():
142+
continue
143+
144+
if builtin is not None:
145+
# Filter by 'builtin' status of plugin
146+
if builtin != plugin.is_builtin:
147+
continue
148+
149+
result.append(plugin)
150+
151+
return result
152+
# endregion
153+
109154
# region loading / unloading
110-
def load_plugins(self, full_reload: bool = False):
155+
def _load_plugins(self, full_reload: bool = False):
111156
"""Load and activate all IntegrationPlugins.
112157
113158
Args:
@@ -175,7 +220,7 @@ def load_plugins(self, full_reload: bool = False):
175220
from plugin.events import trigger_event
176221
trigger_event('plugins_loaded')
177222

178-
def unload_plugins(self, force_reload: bool = False):
223+
def _unload_plugins(self, force_reload: bool = False):
179224
"""Unload and deactivate all IntegrationPlugins.
180225
181226
Args:
@@ -202,7 +247,9 @@ def unload_plugins(self, force_reload: bool = False):
202247
logger.info('Finished unloading plugins')
203248

204249
def reload_plugins(self, full_reload: bool = False, force_reload: bool = False, collect: bool = False):
205-
"""Safely reload.
250+
"""Reload the plugin registry.
251+
252+
This should be considered the single point of entry for loading plugins!
206253
207254
Args:
208255
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
@@ -211,21 +258,25 @@ def reload_plugins(self, full_reload: bool = False, force_reload: bool = False,
211258
"""
212259
# Do not reload when currently loading
213260
if self.is_loading:
214-
return # pragma: no cover
261+
logger.debug("Skipping reload - plugin registry is currently loading")
262+
return
263+
264+
if self.loading_lock.acquire(blocking=False):
215265

216-
logger.info('Start reloading plugins')
266+
logger.info('Plugin Registry: Reloading plugins')
217267

218-
with maintenance_mode_on():
219-
if collect:
220-
logger.info('Collecting plugins')
221-
self.plugin_modules = self.collect_plugins()
268+
with maintenance_mode_on():
269+
if collect:
270+
logger.info('Collecting plugins')
271+
self.plugin_modules = self.collect_plugins()
222272

223-
self.plugins_loaded = False
224-
self.unload_plugins(force_reload=force_reload)
225-
self.plugins_loaded = True
226-
self.load_plugins(full_reload=full_reload)
273+
self.plugins_loaded = False
274+
self._unload_plugins(force_reload=force_reload)
275+
self.plugins_loaded = True
276+
self._load_plugins(full_reload=full_reload)
227277

228-
logger.info('Finished reloading plugins')
278+
self.loading_lock.release()
279+
logger.info('Plugin Registry: Loaded %s plugins', len(self.plugins))
229280

230281
def plugin_dirs(self):
231282
"""Construct a list of directories from where plugins can be loaded"""
@@ -360,30 +411,6 @@ def install_plugin_file(self):
360411

361412
# endregion
362413

363-
# region registry functions
364-
def with_mixin(self, mixin: str, active=None, builtin=None):
365-
"""Returns reference to all plugins that have a specified mixin enabled."""
366-
result = []
367-
368-
for plugin in self.plugins.values():
369-
if plugin.mixin_enabled(mixin):
370-
371-
if active is not None:
372-
# Filter by 'active' status of plugin
373-
if active != plugin.is_active():
374-
continue
375-
376-
if builtin is not None:
377-
# Filter by 'builtin' status of plugin
378-
if builtin != plugin.is_builtin:
379-
continue
380-
381-
result.append(plugin)
382-
383-
return result
384-
# endregion
385-
# endregion
386-
387414
# region general internal loading /activating / deactivating / deloading
388415
def _init_plugins(self, disabled: str = None):
389416
"""Initialise all found plugins.
@@ -540,7 +567,7 @@ def _try_reload(self, cmd, *args, **kwargs):
540567
cmd(*args, **kwargs)
541568
return True, []
542569
except Exception as error: # pragma: no cover
543-
handle_error(error)
570+
handle_error(error, do_raise=False)
544571

545572
def _reload_apps(self, force_reload: bool = False, full_reload: bool = False):
546573
"""Internal: reload apps using django internal functions.
@@ -549,9 +576,7 @@ def _reload_apps(self, force_reload: bool = False, full_reload: bool = False):
549576
force_reload (bool, optional): Also reload base apps. Defaults to False.
550577
full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False.
551578
"""
552-
# If full_reloading is set to true we do not want to set the flag
553-
if not full_reload:
554-
self.is_loading = True # set flag to disable loop reloading
579+
555580
if force_reload:
556581
# we can not use the built in functions as we need to brute force the registry
557582
apps.app_configs = OrderedDict()
@@ -560,7 +585,6 @@ def _reload_apps(self, force_reload: bool = False, full_reload: bool = False):
560585
self._try_reload(apps.populate, settings.INSTALLED_APPS)
561586
else:
562587
self._try_reload(apps.set_installed_apps, settings.INSTALLED_APPS)
563-
self.is_loading = False
564588

565589
def _clean_installed_apps(self):
566590
for plugin in self.installed_apps:
@@ -601,6 +625,68 @@ def _update_urls(self):
601625
clear_url_caches()
602626
# endregion
603627

628+
# region plugin registry hash calculations
629+
def update_plugin_hash(self):
630+
"""When the state of the plugin registry changes, update the hash"""
631+
632+
from common.models import InvenTreeSetting
633+
634+
plg_hash = self.calculate_plugin_hash()
635+
636+
try:
637+
old_hash = InvenTreeSetting.get_setting("_PLUGIN_REGISTRY_HASH", "", create=False, cache=False)
638+
except Exception:
639+
old_hash = ""
640+
641+
if old_hash != plg_hash:
642+
try:
643+
logger.debug("Updating plugin registry hash: %s", str(plg_hash))
644+
InvenTreeSetting.set_setting("_PLUGIN_REGISTRY_HASH", plg_hash, change_user=None)
645+
except Exception as exc:
646+
logger.exception("Failed to update plugin registry hash: %s", str(exc))
647+
648+
def calculate_plugin_hash(self):
649+
"""Calculate a 'hash' value for the current registry
650+
651+
This is used to detect changes in the plugin registry,
652+
and to inform other processes that the plugin registry has changed
653+
"""
654+
655+
from hashlib import md5
656+
657+
data = md5()
658+
659+
# Hash for all loaded plugins
660+
for slug, plug in self.plugins.items():
661+
data.update(str(slug).encode())
662+
data.update(str(plug.version).encode())
663+
data.update(str(plug.is_active).encode())
664+
665+
return str(data.hexdigest())
666+
667+
def check_reload(self):
668+
"""Determine if the registry needs to be reloaded"""
669+
670+
from common.models import InvenTreeSetting
671+
672+
if settings.TESTING:
673+
# Skip if running during unit testing
674+
return
675+
676+
logger.debug("Checking plugin registry hash")
677+
678+
try:
679+
reg_hash = InvenTreeSetting.get_setting("_PLUGIN_REGISTRY_HASH", "", create=False, cache=False)
680+
except Exception as exc:
681+
logger.exception("Failed to retrieve plugin registry hash: %s", str(exc))
682+
return
683+
684+
if reg_hash and reg_hash != self.calculate_plugin_hash():
685+
logger.info("Plugin registry hash has changed - reloading")
686+
self.reload_plugins(full_reload=True, force_reload=True, collect=True)
687+
688+
# endregion
689+
604690

605691
registry: PluginsRegistry = PluginsRegistry()
606692

0 commit comments

Comments
 (0)