Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Redo master-DB-stickiness in a different way.

Now, instead of a post-save hook, assume any POST request is a write, and set a cookie that keeps reads on the master for 15 seconds thereafter. Assumes we manually call using() to target reads to the master for the remainder of a request after a write. Also, renamed everything from "sticky" to "pinned" by popular demand.
  • Loading branch information...
commit b04f1e762978e9c666c3b9f452f89e6d1f5b77d2 1 parent 9fe1baa
@erikrose erikrose authored
View
61 README.rst
@@ -1,3 +1,10 @@
+``multidb`` provides two Django database routers useful in master-slave
+deployments.
+
+
+MasterSlaveRouter
+-----------------
+
With ``multidb.MasterSlaveRouter`` all read queries will go to a slave
database; all inserts, updates, and deletes will do to the ``default``
database.
@@ -25,3 +32,57 @@ If you want to get a connection to a slave in your app, use
import multidb
connection = connections[multidb.get_slave()]
+
+
+PinningMasterSlaveRouter
+------------------------
+
+In some applications, the lag between the master receiving a write and its
+replication to the slaves is enough to cause inconsistency for the end user.
+For example, imagine a scenario with 1 second of replication lag. If a user
+makes a forum post (to the master) and then is redirected to a fully-rendered
+view of it (from a slave) 500ms later, the view will fail. If this is a problem
+in your application, consider using ``multidb.PinningMasterSlaveRouter``. This
+router works in combination with ``multidb.middleware.PinningRouterMiddleware``
+to assure that, after writing to the ``default`` database, future reads from
+the same user agent are directed to the ``default`` database for a configurable
+length of time.
+
+Caveats
+=======
+
+``PinningRouterMiddleware`` identifies database writes solely by request type,
+assuming that any ``POST`` request is a write.
+
+This package makes no attempt to redirect database activity that occurs after a
+write but during the same request. You application is responsible for manually
+directing such activity to ``default`` with, for instance, the ``using`` method
+or keyword argument.
+
+Configuration
+=============
+
+To use ``PinningMasterSlaveRouter``, put it into ``DATABASE_ROUTERS`` in your
+settings::
+
+ DATABASE_ROUTERS = ('multidb.PinningMasterSlaveRouter',)
+
+Then, install the middleware. It must be listed before any other middleware
+which performs database writes::
+
+ MIDDLEWARE_CLASSES = (
+ 'multidb.middleware.PinningRouterMiddleware',
+ ...more middleware here...
+ )
+
+``PinningRouterMiddleware`` attaches a cookie to any user agent who has just
+written. The cookie should be set to expire at a time longer than your
+replication lag. By default, its value is a conservative 15 seconds, but it can
+be adjusted like so::
+
+ MULTIDB_PINNING_SECONDS = 5
+
+If you need to change the name of the cookie, use the ``MULTIDB_PINNING_COOKIE``
+setting::
+
+ MULTIDB_PINNING_COOKIE = 'multidb_pin_writes'
View
28 example-settings.py
@@ -1,28 +0,0 @@
-# An illustrative example settings.py. You need to define DATABASES and
-# SLAVE_DATABASES.
-
-
-# The default database should point to the master.
-DATABASES = {
- 'default': {
- 'NAME': 'master',
- 'ENGINE': 'django.db.backends.mysql',
- 'HOST': '',
- 'PORT': '',
- 'USER': '',
- 'PASSWORD': '',
- 'OPTIONS': {'init_command': 'SET storage_engine=InnoDB'},
- },
- 'slave': {
- 'NAME': 'slave',
- 'ENGINE': 'django.db.backends.mysql',
- 'HOST': '',
- 'PORT': '',
- 'USER': '',
- 'PASSWORD': '',
- 'OPTIONS': {'init_command': 'SET storage_engine=InnoDB'},
- },
-}
-
-# Put the aliases for slave databases in this list.
-SLAVE_DATABASES = ['slave']
View
31 fabfile.py
@@ -0,0 +1,31 @@
+"""
+Creating standalone Django apps is a PITA because you're not in a project, so
+you don't have a settings.py file. I can never remember to define
+DJANGO_SETTINGS_MODULE, so I run these commands which get the right env
+automatically.
+"""
+import functools
+import os
+
+from fabric.api import local, env
+
+
+os.environ['PYTHONPATH'] = os.path.dirname(__file__)
+os.environ['DJANGO_SETTINGS_MODULE'] = 'multidb.tests.settings'
+env.hosts = ['localhost']
+
+
+local = functools.partial(local, capture=False)
+
+
+def shell():
+ local('django-admin.py shell')
+
+
+def test(pdb=False):
+ cmd = 'django-admin.py test'
+
+ if pdb:
+ cmd += ' --pdb --pdb-failures -s'
+
+ local(cmd)
View
47 multidb/__init__.py
@@ -31,8 +31,8 @@
import random
from django.conf import settings
-from django.utils.thread_support import currentThread
-from django.core.signals import post_save, post_delete, request_started
+
+from .pinning import this_thread_is_pinned
DEFAULT_DB_ALIAS = 'default'
@@ -55,29 +55,6 @@ def get_slave():
return slaves.next()
-_requests = {}
-
-
-def get_written():
- return _requests.get(currentThread())
-
-
-def set_written():
- _requests[currentThread()] = True
-
-
-def clear_written():
- try:
- del _requests[currentThread()]
- except KeyError:
- pass
-
-
-request_started.connections(clear_written)
-post_save.connect(set_written)
-post_delete.connect(set_written)
-
-
class MasterSlaveRouter(object):
"""Router that sends all reads to a slave, all writes to default."""
@@ -98,14 +75,16 @@ def allow_syncdb(self, db, model):
return db == DEFAULT_DB_ALIAS
-class StickyMasterSlaveRouter(MasterSlaveRouter):
- """Sends all reads to a slave unless there has already been a write,
- then sends reads to the master for the remainder of the request."""
+class PinningMasterSlaveRouter(MasterSlaveRouter):
+ """Router that sends reads to master iff a certain flag is set. Writes
+ always go to master.
- def db_for_read(self, model, **hints):
- """Send reads to slaves in round-robin, unless there has been a
- write."""
+ Typically, we set a cookie in middleware when the request is a POST and
+ give it a max age that's certain to be longer than the replication lag. The
+ flag comes from that cookie.
- if get_written():
- return DEFAULT_DB_ALIAS
- return get_slave()
+ """
+ def db_for_read(self, model, **hints):
+ """Send reads to slaves in round-robin unless this thread is "stuck" to
+ the master."""
+ return DEFAULT_DB_ALIAS if this_thread_is_pinned() else get_slave()
View
50 multidb/middleware.py
@@ -0,0 +1,50 @@
+from django.conf import settings
+
+from .pinning import pin_this_thread, unpin_this_thread
+
+
+# The name of the cookie that directs a request's reads to the master DB
+PINNING_COOKIE = getattr(settings, 'MULTIDB_PINNING_COOKIE',
+ 'multidb_pin_writes')
+
+
+# The number of seconds for which reads are directed to the master DB after a
+# write
+PINNING_SECONDS = int(getattr(settings, 'MULTIDB_PINNING_SECONDS', 15))
+
+
+class PinningRouterMiddleware(object):
+ """Middleware to support the PinningMasterSlaveRouter
+
+ Attaches a cookie to a user agent who has just written, causing subsequent
+ DB reads (for some period of time, hopefully exceeding replication lag)
+ to be handled by the master.
+
+ When the cookie is detected on a request, sets a thread-local to alert the
+ DB router.
+
+ """
+ def process_request(self, request):
+ """Set the thread's pinning flag according to the presence of the
+ incoming cookie."""
+ if PINNING_COOKIE in request.COOKIES:
+ pin_this_thread()
+ else:
+ # In case the last request this thread served was pinned:
+ unpin_this_thread()
+
+ # We could also set the pinning flag at the top of POST requests.
+ # But, at the moment, we rely on ourselves to be smart enough to
+ # manually direct the necessary requests to the master within the scope
+ # of one request. Too optimistic?
+
+ def process_response(self, request, response):
+ """On a POST request, assume there was a DB write and set the cookie.
+
+ Even if it was already set, reset its expiration time.
+
+ """
+ if request.method == 'POST':
+ response.set_cookie(PINNING_COOKIE, value='y',
+ max_age=PINNING_SECONDS)
+ return response
View
30 multidb/pinning.py
@@ -0,0 +1,30 @@
+"""An encapsulated thread-local variable that indicates whether future DB
+writes should be "stuck" to the master."""
+
+import threading
+
+
+_locals = threading.local()
+
+
+def this_thread_is_pinned():
+ """Return whether the current thread should send all its reads to the
+ master DB."""
+ return getattr(_locals, 'pinned', False)
+
+
+def pin_this_thread():
+ """Mark this thread as "stuck" to the master for all DB access."""
+ _locals.pinned = True
+
+
+def unpin_this_thread():
+ """Unmark this thread as "stuck" to the master for all DB access.
+
+ If the thread wasn't marked, do nothing.
+
+ """
+ try:
+ del _locals.pinned
+ except AttributeError:
+ pass
View
108 multidb/tests/__init__.py
@@ -0,0 +1,108 @@
+from django.http import HttpRequest, HttpResponse
+from django.test import TestCase
+
+from nose.tools import eq_
+
+from multidb import (DEFAULT_DB_ALIAS, MasterSlaveRouter,
+ PinningMasterSlaveRouter, get_slave)
+from multidb.middleware import (PINNING_COOKIE, PINNING_SECONDS,
+ PinningRouterMiddleware)
+from multidb.pinning import (this_thread_is_pinned,
+ pin_this_thread, unpin_this_thread)
+
+
+class UnpinningTestCase(TestCase):
+ """Test case that unpins the thread on tearDown"""
+
+ def tearDown(self):
+ unpin_this_thread()
+
+
+class MasterSlaveRouterTests(TestCase):
+ """Tests for MasterSlaveRouter"""
+
+ def test_db_for_read(self):
+ eq_(MasterSlaveRouter().db_for_read(None), get_slave())
+ # TODO: Test the round-robin functionality.
+
+ def test_db_for_write(self):
+ eq_(MasterSlaveRouter().db_for_write(None), DEFAULT_DB_ALIAS)
+
+ def test_allow_syncdb(self):
+ """Make sure allow_syncdb() does the right thing for both masters and
+ slaves"""
+ router = MasterSlaveRouter()
+ assert router.allow_syncdb(DEFAULT_DB_ALIAS, None)
+ assert not router.allow_syncdb(get_slave(), None)
+
+
+class SettingsTests(TestCase):
+ """Tests for settings defaults"""
+
+ def test_cookie_default(self):
+ """Check that the cookie name has the right default."""
+ eq_(PINNING_COOKIE, 'multidb_pin_writes')
+
+ def test_pinning_seconds_default(self):
+ """Make sure the cookie age has the right default."""
+ eq_(PINNING_SECONDS, 15)
+
+
+class PinningTests(UnpinningTestCase):
+ """Tests for "pinning" functionality, above and beyond what's inherited
+ from MasterSlaveRouter"""
+
+ def test_pinning_encapsulation(self):
+ """Check the pinning getters and setters."""
+ assert not this_thread_is_pinned(), \
+ "Thread started out pinned or this_thread_is_pinned() is broken."
+
+ pin_this_thread()
+ assert this_thread_is_pinned(), \
+ "pin_this_thread() didn't pin the thread."
+
+ unpin_this_thread()
+ assert not this_thread_is_pinned(), \
+ "Thread remained pinned after unpin_this_thread()."
+
+ def test_pinned_reads(self):
+ """Test PinningMasterSlaveRouter.db_for_read() when pinned and when
+ not."""
+ router = PinningMasterSlaveRouter()
+
+ eq_(router.db_for_read(None), get_slave())
+
+ pin_this_thread()
+ eq_(router.db_for_read(None), DEFAULT_DB_ALIAS)
+
+
+class MiddlewareTests(UnpinningTestCase):
+ """Tests for the middleware that supports pinning"""
+
+ def test_process_request(self):
+ """Make sure the thread gets set as pinned when the cookie is on the
+ request and as unpinned when it isn't."""
+ request = HttpRequest()
+ request.COOKIES[PINNING_COOKIE] = 'y'
+
+ middleware = PinningRouterMiddleware()
+ middleware.process_request(request)
+ assert this_thread_is_pinned()
+
+ del request.COOKIES[PINNING_COOKIE]
+ middleware.process_request(request)
+ assert not this_thread_is_pinned()
+
+ def test_process_response(self):
+ """Make sure the cookie gets set on POST requests and not otherwise."""
+ request = HttpRequest()
+ middleware = PinningRouterMiddleware()
+
+ response = middleware.process_response(request, HttpResponse())
+ assert PINNING_COOKIE not in response.cookies
+
+ request.method = 'POST'
+ response = middleware.process_response(request, HttpResponse())
+ assert PINNING_COOKIE in response.cookies
+ eq_(response.cookies[PINNING_COOKIE]['max-age'],
+ PINNING_SECONDS)
View
23 multidb/tests/settings.py
@@ -0,0 +1,23 @@
+# A Django settings module to support the tests
+
+TEST_RUNNER = 'django_nose.runner.NoseTestSuiteRunner'
+
+# The default database should point to the master.
+DATABASES = {
+ 'default': {
+ 'NAME': 'master',
+ 'ENGINE': 'django.db.backends.sqlite3',
+ },
+ 'slave': {
+ 'NAME': 'slave',
+ 'ENGINE': 'django.db.backends.sqlite3',
+ },
+}
+
+# Put the aliases for slave databases in this list.
+SLAVE_DATABASES = ['slave']
+
+# If you use PinningMasterSlaveRouter and its associated middleware, you can
+# customize the cookie name and its lifetime like so:
+# MULTIDB_PINNING_COOKIE = 'multidb_pin_writes"
+# MULTIDB_PINNING_SECONDS = 15
View
3  requirements.txt
@@ -0,0 +1,3 @@
+Django==1.2.1
+Fabric==0.9.1
+django-nose==0.1
View
1  setup.py
@@ -13,6 +13,7 @@
packages=find_packages(),
include_package_data=True,
zip_safe=False,
+ test_requires=['Fabric', 'django-nose'],
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Web Environment',
Please sign in to comment.
Something went wrong with that request. Please try again.