Skip to content
This repository was archived by the owner on Jan 29, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
34 changes: 28 additions & 6 deletions django_replicated/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,35 @@ 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 = []
self._context.chosen = {}
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):
Expand Down Expand Up @@ -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':
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions django_replicated/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
32 changes: 32 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'