diff --git a/README.md b/README.md index 7b20e6a..1624138 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,12 @@ SQL operations. REPLICATED_DATABASE_SLAVES = ['slave1', 'slave2'] - The 'default' database is always treated as master. + The 'default' database is by default treated as master. + + OPTIONALLY, you can teach which databases are the masters, for master-master + replication scenarios: + + REPLICATED_DATABASE_MASTERS = ['default', 'master2'] 1. Configure a replication router: diff --git a/django_replicated/router.py b/django_replicated/router.py index f47b368..5d016ae 100644 --- a/django_replicated/router.py +++ b/django_replicated/router.py @@ -15,10 +15,11 @@ def __init__(self): self.DEFAULT_DB_ALIAS = DEFAULT_DB_ALIAS self.DOWNTIME = settings.REPLICATED_DATABASE_DOWNTIME + self.MASTERS = settings.REPLICATED_DATABASE_MASTERS or [DEFAULT_DB_ALIAS] self.SLAVES = settings.REPLICATED_DATABASE_SLAVES or [DEFAULT_DB_ALIAS] self.CHECK_STATE_ON_WRITE = settings.REPLICATED_CHECK_STATE_ON_WRITE - self.all_allowed_aliases = [self.DEFAULT_DB_ALIAS] + self.SLAVES + self.all_allowed_aliases = self.MASTERS + self.SLAVES def _init_context(self): self._context.state_stack = [] @@ -26,6 +27,23 @@ def _init_context(self): self._context.state_change_enabled = True self._context.inited = True + def _get_actual_master(self): + try: + chosen = self._context.actual_master + if not self.is_alive(chosen): + raise RuntimeError() + except (AttributeError, RuntimeError): + # Be predictable here. No shuffle for master + for db in self.MASTERS: + if self.is_alive(db): + chosen = db + break + else: + chosen = self.DEFAULT_DB_ALIAS + + self.context.actual_master = chosen + return chosen + @property def context(self): if not getattr(self._context, 'inited', False): @@ -74,9 +92,10 @@ def db_for_write(self, model, **hints): if self.CHECK_STATE_ON_WRITE and self.state() != 'master': raise RuntimeError('Trying to access master database in slave state') - self.context.chosen['master'] = self.DEFAULT_DB_ALIAS + actual_master = self._get_actual_master() + self.context.chosen['master'] = actual_master - return self.DEFAULT_DB_ALIAS + return actual_master def db_for_read(self, model, **hints): if self.state() == 'master': @@ -87,10 +106,13 @@ def db_for_read(self, model, **hints): slaves = self.SLAVES[:] random.shuffle(slaves) + masters = self.MASTERS[:] + random.shuffle(masters) - for slave in slaves: - if self.is_alive(slave): - chosen = slave + # Try masters if slaves cannot be used + for db in slaves + masters: + if self.is_alive(db): + chosen = db break else: chosen = self.DEFAULT_DB_ALIAS diff --git a/django_replicated/settings.py b/django_replicated/settings.py index 9274856..aa6a9a2 100644 --- a/django_replicated/settings.py +++ b/django_replicated/settings.py @@ -7,6 +7,9 @@ # List of slave database aliases. Default database is always master REPLICATED_DATABASE_SLAVES = [] +# List of master database aliases. Default is to only be the 'default' database +REPLICATED_DATABASE_MASTERS = [] + # View name to state mapping REPLICATED_VIEWS_OVERRIDES = {} diff --git a/tests/conftest.py b/tests/conftest.py index 1c366ef..a6aff26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ def pytest_configure(): settings.configure(**dict(replicated_settings.__dict__, DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3'}, + 'master2': {'ENGINE': 'django.db.backends.sqlite3'}, 'slave1': {'ENGINE': 'django.db.backends.sqlite3'}, 'slave2': {'ENGINE': 'django.db.backends.sqlite3'},}, REPLICATED_DATABASE_SLAVES=['slave1', 'slave2'], diff --git a/tests/test_router.py b/tests/test_router.py index ed8203d..25b2f47 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -6,6 +6,7 @@ from django import db from django.db import models, router as django_router +from django.test.utils import override_settings from django_replicated.router import ReplicationRouter @@ -61,3 +62,34 @@ def test_router_allow_relation(model): obj2._state.db = 'slave2' assert django_router.allow_relation(obj1, obj2) + + +def test_router_multimaster(model): + with override_settings(REPLICATED_DATABASE_MASTERS=['default', 'master2']): + router = ReplicationRouter() + + assert router.db_for_write(model) == 'default' + assert router.db_for_write(model) == 'default', 'Master should not be random on choices' + assert router.db_for_write(model) == 'default', 'Master should not be random on choices' + + with mock.patch.object(router, 'is_alive') as is_alive_mock: + + def default_is_down(dbname): + return False if dbname == 'default' else True + + def master2_is_down(dbname): + return False if dbname == 'master2' else True + + def everything_is_up(dbname): + return True + + is_alive_mock.side_effect = default_is_down + assert router.db_for_write(model) == 'master2', 'Should switch to first working master on fail' + + is_alive_mock.side_effect = everything_is_up + assert router.db_for_write(model) == 'master2', 'Chosen master should be kept unless failed' + assert router.db_for_write(model) == 'master2', 'Chosen master should be kept unless failed' + assert router.db_for_write(model) == 'master2', 'Chosen master should be kept unless failed' + + is_alive_mock.side_effect = master2_is_down + assert router.db_for_write(model) == 'default', 'Should switch to first working master on fail'