diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 382a973906..46e57f0c10 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -1027,7 +1027,11 @@ class PluginsRegistry: data = md5() # Hash for all loaded plugins - for slug, plug in self.plugins.items(): + # Note: Sort by slug, so the hash is independent of discovery order. + # Different processes can discover the same plugins in a different + # order, and the hash must represent the registry *state*, not the + # iteration order of any particular process. + for slug, plug in sorted(self.plugins.items(), key=lambda item: item[0]): data.update(str(slug).encode()) data.update(str(plug.name).encode()) data.update(str(plug.version).encode()) diff --git a/src/backend/InvenTree/plugin/test_plugin.py b/src/backend/InvenTree/plugin/test_plugin.py index c918eadc3d..e4fb3ad27d 100644 --- a/src/backend/InvenTree/plugin/test_plugin.py +++ b/src/backend/InvenTree/plugin/test_plugin.py @@ -538,6 +538,30 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase): registry.registry_hash = 'abc' self.assertTrue(registry.check_reload()) + def test_registry_hash_order_independence(self): + """Test that the registry hash does not depend on plugin iteration order. + + Different processes (gunicorn workers, background worker, shell) can + discover the same set of plugins in a different order. If the hash + depends on iteration order, processes disagree about the hash for the + same registry state, and ping-pong each other into endless reloads + via check_reload. + """ + original_plugins = registry.plugins + + # Reversing a dict with fewer than 2 entries would not change anything + self.assertGreater(len(original_plugins), 1) + + try: + hash_original = registry.calculate_plugin_hash() + + # Simulate a process which discovered the same plugins in reverse order + registry.plugins = dict(reversed(list(original_plugins.items()))) + + self.assertEqual(hash_original, registry.calculate_plugin_hash()) + finally: + registry.plugins = original_plugins + def test_builtin_mandatory_plugins(self): """Test that mandatory builtin plugins are always loaded.""" from plugin.models import PluginConfig