Routers and Routing strategies¶
Note
This section currently pertains only to the Django ORM as the other ORM libraries that ship with this package currently don't provide support for routing to multiple databases at the ORM level in a thread-safe manner.
Since the Django ORM supports routing to multiple databases at the ORM level using a concept called database routers, there are a few scenarios to consider with respect to defining custom routers and the different routing strategies that are possible.
A router in Django when implemented, is responsible for providing one or more of the following functionalities:
- Provides a database for every write operation made via the ORM.
- Provides a database for every read operation made via the ORM.
- Tells whether relations between objects should be allowed or not.
- Tells which models should be migrated onto a particular database.
Scenario 1: Single DB replicated across tenants¶
Consider the following example of a conventionally built single-tenant Django app.
# settings.py file
import os
DATABASES = {
"default": {
"NAME": os.environ["PG_DATABASE"],
"USER": os.environ.get("PG_USER"),
"PASSWORD": os.environ.get("PG_PASSWORD"),
"HOST": os.environ["PG_HOST"],
"PORT": os.environ["PG_PORT"],
"OPTIONS": {...}
}
}
# KV store settings for the above template
{
"some_tenant_a_prefix_default": {
"NAME": "some_value",
"USER": "some_value",
"PASSWORD": "some_value",
"HOST": "some_value",
"PORT": "some_value",
},
"some_tenant_b_prefix_default": {
"NAME": "some_value",
"USER": "some_value",
"PASSWORD": "some_value",
"HOST": "some_value",
"PORT": "some_value",
}
}
From the above example, we infer that the default
key in the DATABASES
setting is actually a template
and the actual configuration for this template, for each tenant, is defined in the KV store.
Now for this scenario, the expected behavior of the router is as follows:
- All writes should be sent to the respective tenant's DB with alias of the form
some_tenant_prefix_default
. - All reads should be sent to the respective tenant's DB with alias of the form
some_tenant_prefix_default
. - All relations between objects should be allowed within a particular tenant's DB.
- All models present in the app should be migrated to all tenant DBs'.
The above behavior is exactly what the app will get when it plugs-in the DjangoOrmRouter
class that ships
with the package.
# settings.py
...
DATABASE_ROUTERS = ['tenant_router.orm_backends.django_orm.router.DjangoOrmRouter']
...
Scenario 2: Central / Tenant-specific DBs¶
This is a typical scenario in a multi-tenant cloud environment wherein the web app interacts with both a tenant-specific DB and a central DB, which is common across tenants. The diagram below illustrates this case.
// diag goes here.
The configuration in settings.py
could be as follows:
import os
...
DATABASES = {
# template alias
"default": {
"NAME": os.environ["PG_DATABASE"],
"USER": os.environ.get("PG_USER"),
"PASSWORD": os.environ.get("PG_PASSWORD"),
"HOST": os.environ["PG_HOST"],
"PORT": os.environ["PG_PORT"],
"OPTIONS": {...}
},
# reserved key
"central_db": {
"NAME": os.environ["PG_DATABASE"],
"USER": os.environ.get("PG_USER"),
"PASSWORD": os.environ.get("PG_PASSWORD"),
"HOST": os.environ["PG_HOST"],
"PORT": os.environ["PG_PORT"],
"OPTIONS": {...}
}
}
# KV store settings for the above template
{
"some_tenant_a_prefix_default": {
"NAME": "some_value",
"USER": "some_value",
"PASSWORD": "some_value",
"HOST": "some_value",
"PORT": "some_value",
},
"some_tenant_b_prefix_default": {
"NAME": "some_value",
"USER": "some_value",
"PASSWORD": "some_value",
"HOST": "some_value",
"PORT": "some_value",
}
}
From the above example, we infer that the default
key is treated as a template alias for which the
actual configuration comes from the KV store, for each tenant. However the central_db
key is
treated as a reserved alias as its configuration comes from the env
and not the KV store since it remains the same across all tenants.
In such a case, the expected behavior of the router is as follows:
- All writes should be sent either to a tenant-specific DB or the central DB depending on some condition.
- All reads should be sent either to a tenant-specific DB or the central DB depending on some condition.
- Relations between objects should be allowed within the same DB (either tenant-specific / central)
- A migration strategy which decides whether a model should be migrated on to the central DB or tenant-specific DB.
In order to achieve the above, the following modifications should be made:
-
Action:
Provide a router for each reserved alias defined and stack it on top of theDjangoOrmRouter
class in theDATABASE_ROUTERS
setting.
Purpose:
Since all routers defined for each reserved alias would be consulted in order before falling back to theDjangoOrmRouter
, it ensures that custom routing/migration logic for each reserved alias would be tried and executed before executing the routing/migration logic for template aliases. -
Action:
Provide keys which should be treated as reserved aliases to themanager
class.
Purpose:
This helps in isolating the template aliases from the reserved ones so as to power up a few public APIs of themanager
class and also for other internal purposes. -
Action:
A custom migration strategy callable should be plugged into theDjangoOrmRouter
class. This would be called from within theallow_migrate
method whose return value will be the value that this callable returns.
Purpose:
This callable would decide whether a particular model gets migrated onto DBs' defined as part of the template aliases.
Example:
# custom router for the 'central_db' reserved alias
class CustomRouter:
def db_for_read(self, model, **hints):
if some_condition:
return "central_db"
return None
def db_for_write(self, model, **hints):
if some_condition:
return "central_db"
return None
def allow_relation(self, *args, **kwargs):
return None
def allow_migrate(self, db, app_label, model_name=None, **hints):
if some_condition:
return True
else:
return False
return None
# sample migrate_strategy_callable
def migrate_strategy(
router, db, app_label, model_name=None, **hints
):
if some_condition:
return True
else:
return False
return None
# modified settings
DATABASE_ROUTERS = [
'path.to.CustomRouter',
'tenant_router.orm_backends.django_orm.router.DjangoOrmRouter'
]
TENANT_ROUTER_ORM_SETTINGS = {
'django_orm': {
'MANAGER': 'tenant_router.orm_backends.django_orm.manager.DjangoOrmManager',
'SETTINGS_KEY': 'DATABASES',
'OPTIONS': {
# marking the 'central_db' key as a reserved alias
'RESERVED_CONN_ALIASES': {'central_db'},
# plugging in the callable which defines the custom migrate strategy
'ROUTER_OPTS': {
'MIGRATE_STRATEGY': 'path.to.migrate_strategy_callable'
}
}
}
}