Skip to content
Browse files

Synchronize extension state across threads/processes/servers.

A lot of extension state was local to the process modifying the state.
If one process enabled an extension or modified an extension's settings,
other processes with existing knowledge of extensions would be out of
date. Only new processes would get the new state.

We now use a cache key to synchronize the state of an ExtensionManager.
On every request, we check each ExtensionManager to see if it has
expired. If it has, the ExtensionManager will be fully reloaded.

Each unique type of ExtensionManager (as indicated by its 'key'
property) will have its own cache key.

This allows us to ensure synchronicity across processes or even servers.

Unit tests were added to ensure this does not break.
  • Loading branch information...
1 parent 84afd3e commit ef103153e490a55b0393ae08b42c1772d621a7a2 @chipx86 chipx86 committed Nov 21, 2012
Showing with 218 additions and 31 deletions.
  1. +87 −14 djblets/extensions/base.py
  2. +22 −3 djblets/extensions/middleware.py
  3. +105 −11 djblets/extensions/tests.py
  4. +3 −2 djblets/testing/urls.py
  5. +1 −1 tests/settings.py
View
101 djblets/extensions/base.py
@@ -23,23 +23,25 @@
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+import datetime
import logging
import os
import pkg_resources
import shutil
import sys
+import time
from django.conf import settings
from django.conf.urls.defaults import patterns, include
from django.contrib.admin.sites import AdminSite
+from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.core.management import call_command
from django.core.management.base import CommandError
from django.core.urlresolvers import get_resolver, get_mod_func
from django.db.models import loading
from django.utils.importlib import import_module
from django.utils.module_loading import module_has_submodule
-
from django_evolution.management.commands.evolve import Command as Evolution
from djblets.extensions.errors import EnablingExtensionError, \
@@ -48,6 +50,7 @@
from djblets.extensions.models import RegisteredExtension
from djblets.extensions.signals import extension_initialized, \
extension_uninitialized
+from djblets.util.misc import make_cache_key
@@ -120,6 +123,9 @@ def save(self):
registration.settings = dict(self)
registration.save()
+ # Make sure others are aware that the configuration changed.
+ self.extension.extension_manager._bump_sync_gen()
+
class Extension(object):
"""Base class for an extension.
@@ -295,10 +301,31 @@ def __init__(self, key):
self._extension_classes = {}
self._extension_instances = {}
+ # State synchronization
+ self._sync_key = make_cache_key('extensionmgr:%s:gen' % key)
+ self._last_sync_gen = None
+
self._admin_ext_resolver = get_resolver(None)
_extension_managers.append(self)
+ def is_expired(self):
+ """Returns whether or not the extension state is possibly expired.
+
+ Extension state covers the lists of extensions and each extension's
+ configuration. It can expire if the state synchronization value
+ falls out of cache or is changed.
+
+ Each ExtensionManager has its own state synchronization cache key.
+ """
+ sync_gen = cache.get(self._sync_key)
+
+ return (sync_gen is None or
+ (type(sync_gen) is int and sync_gen != self._last_sync_gen))
+
+ def clear_sync_cache(self):
+ cache.delete(self._sync_key)
+
def get_absolute_url(self):
return self._admin_ext_resolver.reverse(
"djblets.extensions.views.extension_list")
@@ -370,7 +397,11 @@ def enable_extension(self, extension_id):
ext_class.registration.enabled = True
ext_class.registration.save()
- return self._init_extension(ext_class)
+ extension = self._init_extension(ext_class)
+
+ self._bump_sync_gen()
+
+ return extension
def disable_extension(self, extension_id):
"""Disables an extension.
@@ -397,14 +428,23 @@ def disable_extension(self, extension_id):
extension.registration.enabled = False
extension.registration.save()
- def load(self):
+ self._bump_sync_gen()
+
+ def load(self, full_reload=False):
"""
Loads all known extensions, initializing any that are recorded as
being enabled.
If this is called a second time, it will refresh the list of
- extensions, adding new ones and removing deleted ones.
+ extensions, adding new ones and removing deleted ones.o
+
+ If full_reload is passed, all state is cleared and we reload all
+ extensions and state from scratch.
"""
+ if full_reload:
+ # We're reloading everything, so nuke all the cached copies.
+ self._clear_extensions()
+
# Preload all the RegisteredExtension objects
registered_extensions = {}
for registered_ext in RegisteredExtension.objects.all():
@@ -430,7 +470,8 @@ def load(self):
if not getattr(ext_class, "info", None):
ext_class.info = ExtensionInfo(entrypoint, ext_class)
except Exception, e:
- print "Error loading extension %s: %s" % (entrypoint.name, e)
+ logging.error("Error loading extension %s: %s" %
+ (entrypoint.name, e))
continue
# A class's extension ID is its class name. We want to
@@ -449,20 +490,17 @@ def load(self):
if class_name in registered_extensions:
registered_ext = registered_extensions[class_name]
else:
- try:
- registered_ext = RegisteredExtension.objects.get(
- class_name=class_name)
- except RegisteredExtension.DoesNotExist:
- registered_ext = RegisteredExtension(
+ registered_ext, is_new = \
+ RegisteredExtension.objects.get_or_create(
class_name=class_name,
- name=entrypoint.dist.project_name
- )
- registered_ext.save()
+ defaults={
+ 'name': entrypoint.dist.project_name
+ })
ext_class.registration = registered_ext
if (ext_class.registration.enabled and
- not ext_class.id in self._extension_instances):
+ ext_class.id not in self._extension_instances):
self._init_extension(ext_class)
# At this point, if we're reloading, it's possible that the user
@@ -483,6 +521,26 @@ def load(self):
[self.get_installed_extension(requirement_id)
for requirement_id in ext_class.requirements]
+ # Add the sync generation if it doesn't already exist.
+ self._add_new_sync_gen()
+ self._last_sync_gen = cache.get(self._sync_key)
+
+ def _clear_extensions(self):
+ """Clear the entire list of known extensions.
+
+ This will bring the ExtensionManager back to the state where
+ it doesn't yet know about any extensions, requiring a re-load.
+ """
+ for extension in self._extension_instances.values():
+ self._uninit_extension(extension)
+
+ for extension_class in self._extension_classes.values():
+ delattr(extension_class, 'info')
+ delattr(extension_class, 'registration')
+
+ self._extension_classes = {}
+ self._extension_instances = {}
+
def _init_extension(self, ext_class):
"""Initializes an extension.
@@ -694,6 +752,21 @@ def _remove_from_installed_apps(self, extension):
def _entrypoint_iterator(self):
return pkg_resources.iter_entry_points(self.key)
+ def _bump_sync_gen(self):
+ """Bumps the synchronization generation value.
+
+ If there's an existing synchronization generation in cache,
+ increment it. Otherwise, start fresh with a new one.
+ """
+ try:
+ self._last_sync_gen = cache.incr(self._sync_key)
+ except ValueError:
+ self._last_sync_gen = self._add_new_sync_gen()
+
+ def _add_new_sync_gen(self):
+ val = time.mktime(datetime.datetime.now().timetuple())
+ return cache.add(self._sync_key, int(val))
+
def get_extension_managers():
return _extension_managers
View
25 djblets/extensions/middleware.py
@@ -23,9 +23,28 @@
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+from djblets.extensions.base import get_extension_managers
+
+
class ExtensionsMiddleware(object):
- """Middleware that takes the kwargs dict passed to a view, and
- stashes it into the request.
- """
+ """Middleware to manage extension lifecycles and data."""
+ def process_request(self, request):
+ self._check_expired()
+
def process_view(self, request, view, args, kwargs):
request._djblets_extensions_kwargs = kwargs
+
+ def _check_expired(self):
+ """Checks each ExtensionManager for expired extension state.
+
+ When the list of extensions on an ExtensionManager changes, or when
+ the configuration of an extension changes, any other threads/processes
+ holding onto extensions and configuration will go stale. This function
+ will check each of those to see if they need to re-load their
+ state.
+
+ This is meant to be called before every HTTP request.
+ """
+ for extension_manager in get_extension_managers():
+ if extension_manager.is_expired():
+ extension_manager.load(full_reload=True)
View
116 djblets/extensions/tests.py
@@ -35,6 +35,7 @@
ExtensionInfo, ExtensionManager, \
Settings
from djblets.extensions.hooks import TemplateHook, URLHook
+from djblets.extensions.models import RegisteredExtension
from djblets.testing.testcases import TestCase
@@ -263,18 +264,14 @@ def test_remove_hook(self):
self.assertTrue(self.dummy_hook not in self.extension_hook_class.hooks)
-class TestExtensionWithoutRegistration(Extension):
- """An empty, dummy extension for testing"""
- pass
-
-
class ExtensionManagerTest(TestCase):
-
def setUp(self):
+ class TestExtension(Extension):
+ """An empty, dummy extension for testing"""
+ pass
+
self.key = 'test_key'
- self.extension_class = TestExtensionWithoutRegistration
- self.extension_id = \
- 'djblets.extensions.tests.TestExtensionWithoutRegistration'
+ self.extension_class = TestExtension
self.manager = ExtensionManager(self.key)
self.fake_entrypoint = Mock()
self.fake_entrypoint.load = Mock(return_value=self.extension_class)
@@ -317,6 +314,9 @@ def setUp(self):
)
self.manager.load()
+ def tearDown(self):
+ self.manager.clear_sync_cache()
+
def test_added_to_extension_managers(self):
"""An ExtensionManager gets added to the _extension_managers list
in the djblets.extensions.base module."""
@@ -326,14 +326,14 @@ def test_get_enabled_extensions_returns_empty(self):
"""An ExtensionManager should return an empty collection when asked
for the enabled extensions, if there are no extensions currently
enabled."""
- self.assertTrue(len(self.manager.get_enabled_extensions()) == 0)
+ self.assertEqual(len(self.manager.get_enabled_extensions()), 0)
def test_load(self):
"""An ExtensionManager should load any extensions that it finds
registered at entrypoints with its key. The loaded extension
should have an ExtensionRegistration and ExtensionInfo attached
to its class."""
- self.assertTrue(len(self.manager.get_installed_extensions()) == 1)
+ self.assertEqual(len(self.manager.get_installed_extensions()), 1)
self.assertTrue(self.extension_class in
self.manager.get_installed_extensions())
self.assertTrue(hasattr(self.extension_class, 'info'))
@@ -343,6 +343,100 @@ def test_load(self):
self.assertEqual(self.extension_class.registration.name,
self.test_project_name)
+ def test_load_full_reload_hooks(self):
+ """Testing ExtensionManager.load(full_reload=True) with hook registration."""
+ self.assertEqual(len(self.manager.get_installed_extensions()), 1)
+
+ extension = self.extension_class()
+ extension = self.manager.enable_extension(self.extension_class.id)
+
+ URLHook(extension, ())
+ self.assertEqual(len(URLHook.hooks), 1)
+ self.assertEqual(URLHook.hooks[0].extension, extension)
+
+ self.manager.load(full_reload=True)
+
+ self.assertEqual(len(URLHook.hooks), 0)
+
+ def test_extension_list_sync(self):
+ """Testing extension list synchronization cross-process."""
+ key = 'extension-list-sync'
+
+ manager1 = ExtensionManager(key)
+ manager2 = ExtensionManager(key)
+
+ for manager in (manager1, manager2):
+ manager._entrypoint_iterator = Mock(
+ return_value=[self.fake_entrypoint]
+ )
+
+ manager1.load()
+ manager2.load()
+
+ self.assertEqual(len(manager1.get_installed_extensions()), 1)
+ self.assertEqual(len(manager2.get_installed_extensions()), 1)
+ self.assertEqual(len(manager1.get_enabled_extensions()), 0)
+ self.assertEqual(len(manager2.get_enabled_extensions()), 0)
+
+ manager1.enable_extension(self.extension_class.id)
+ self.assertEqual(len(manager1.get_enabled_extensions()), 1)
+ self.assertEqual(len(manager2.get_enabled_extensions()), 0)
+
+ self.assertFalse(manager1.is_expired())
+ self.assertTrue(manager2.is_expired())
+
+ manager2.load(full_reload=True)
+ self.assertEqual(len(manager1.get_enabled_extensions()), 1)
+ self.assertEqual(len(manager2.get_enabled_extensions()), 1)
+ self.assertFalse(manager1.is_expired())
+ self.assertFalse(manager2.is_expired())
+
+ def test_extension_settings_sync(self):
+ """Testing extension settings synchronization cross-process."""
+ key = 'extension-settings-sync'
+ setting_key = 'foo'
+ setting_val = 'abc123'
+
+ manager1 = ExtensionManager(key)
+ manager2 = ExtensionManager(key)
+
+ for manager in (manager1, manager2):
+ manager._entrypoint_iterator = Mock(
+ return_value=[self.fake_entrypoint]
+ )
+
+ manager1.load()
+
+ extension1 = manager1.enable_extension(self.extension_class.id)
+
+ manager2.load()
+
+ self.assertFalse(manager1.is_expired())
+ self.assertFalse(manager2.is_expired())
+
+ extension2 = manager2.get_enabled_extension(self.extension_class.id)
+ self.assertNotEqual(extension2, None)
+
+ self.assertFalse(setting_key in extension1.settings)
+ self.assertFalse(setting_key in extension2.settings)
+ extension1.settings[setting_key] = setting_val
+ extension1.settings.save()
+
+ self.assertFalse(setting_key in extension2.settings)
+
+ self.assertFalse(manager1.is_expired())
+ self.assertTrue(manager2.is_expired())
+
+ manager2.load(full_reload=True)
+ extension2 = manager2.get_enabled_extension(self.extension_class.id)
+
+ self.assertFalse(manager1.is_expired())
+ self.assertFalse(manager2.is_expired())
+ self.assertTrue(setting_key in extension1.settings)
+ self.assertTrue(setting_key in extension2.settings)
+ self.assertEqual(extension1.settings[setting_key], setting_val)
+ self.assertEqual(extension2.settings[setting_key], setting_val)
+
class URLHookTest(TestCase):
def setUp(self):
View
5 djblets/testing/urls.py
@@ -1,6 +1,7 @@
-from django.conf.urls.defaults import patterns, url
+from django.conf.urls.defaults import patterns, url, include
urlpatterns = patterns('djblets.extensions.tests',
- url(r'^$', 'test_view_method', name="test-url-name")
+ url(r'^$', 'test_view_method', name="test-url-name"),
+ url(r'^admin/extensions/', include('djblets.extensions.urls')),
)
View
2 tests/settings.py
@@ -95,4 +95,4 @@
INSTALLED_APPS += ["djblets.%s" % entry]
-print INSTALLED_APPS
+INSTALLED_APPS += ['django_evolution']

0 comments on commit ef10315

Please sign in to comment.
Something went wrong with that request. Please try again.