Skip to content

Commit e72f1e5

Browse files
committed
Allow for explicit use of database replicas
1 parent a7302a3 commit e72f1e5

File tree

3 files changed

+125
-3
lines changed

3 files changed

+125
-3
lines changed

settings/dev.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@
3333
"task_processor": dj_database_url.parse(
3434
"postgresql://postgres@localhost:6544/postgres",
3535
),
36+
# Dummy replicas
37+
"replica_1": default_database_url,
38+
"replica_2": default_database_url,
39+
"replica_3": default_database_url,
3640
}
41+
REPLICA_READ_STRATEGY = "distributed"
3742
TASK_PROCESSOR_DATABASES = ["default"]
3843
INSTALLED_APPS = [
3944
"django.contrib.auth",

src/common/core/utils.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1+
import enum
12
import json
23
import pathlib
4+
import random
35
from functools import lru_cache
4-
from typing import NotRequired, TypedDict
6+
from itertools import cycle
7+
from typing import NotRequired, TypedDict, TypeVar
58

69
from django.conf import settings
710
from django.contrib.auth import get_user_model
811
from django.contrib.auth.models import AbstractBaseUser
9-
from django.db.models import Manager
12+
from django.db import connections
13+
from django.db.models import Manager, Model
1014

1115
UNKNOWN = "unknown"
1216
VERSIONS_INFO_FILE_LOCATION = ".versions.json"
1317

18+
ModelType = TypeVar("ModelType", bound=Model)
19+
20+
21+
class ReplicaReadStrategy(enum.StrEnum):
22+
DISTRIBUTED = enum.auto()
23+
SEQUENTIAL = enum.auto()
24+
1425

1526
class SelfHostedData(TypedDict):
1627
has_users: bool
@@ -114,3 +125,24 @@ def get_file_contents(file_path: str) -> str | None:
114125
return f.read().replace("\n", "")
115126
except FileNotFoundError:
116127
return None
128+
129+
130+
def using_database_replica(manager: Manager[ModelType]) -> Manager[ModelType]:
131+
local_replicas = [name for name in connections if name.startswith("replica_")]
132+
133+
if not local_replicas:
134+
return manager
135+
136+
if settings.REPLICA_READ_STRATEGY == ReplicaReadStrategy.SEQUENTIAL:
137+
global _sequential_replica_manager
138+
try:
139+
isinstance(_sequential_replica_manager, cycle) # type: ignore[name-defined]
140+
except NameError:
141+
_sequential_replica_manager = cycle(local_replicas) # type: ignore[name-defined]
142+
finally:
143+
return manager.db_manager(next(_sequential_replica_manager)) # type: ignore[name-defined]
144+
145+
if settings.REPLICA_READ_STRATEGY == ReplicaReadStrategy.DISTRIBUTED:
146+
return manager.db_manager(random.choice(local_replicas))
147+
148+
raise NotImplementedError(f"Unsupported strategy: {settings.REPLICA_READ_STRATEGY}")

tests/unit/common/core/test_utils.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import json
22

33
import pytest
4+
from django.contrib.auth import get_user_model
45
from pyfakefs.fake_filesystem import FakeFilesystem
5-
from pytest_django.fixtures import SettingsWrapper
6+
from pytest_django.fixtures import DjangoAssertNumQueries, SettingsWrapper
7+
from pytest_mock import MockerFixture
68

79
from common.core.utils import (
810
get_file_contents,
@@ -13,6 +15,7 @@
1315
is_enterprise,
1416
is_oss,
1517
is_saas,
18+
using_database_replica,
1619
)
1720

1821
pytestmark = pytest.mark.django_db
@@ -181,3 +184,85 @@ def test_get_version__invalid_file_contents__returns_unknown(
181184

182185
# Then
183186
assert result == "unknown"
187+
188+
189+
@pytest.mark.django_db(databases="__all__")
190+
def test_maybe_use_replicas__no_replicas__points_to_default(
191+
django_assert_num_queries: DjangoAssertNumQueries,
192+
mocker: MockerFixture,
193+
) -> None:
194+
# Given
195+
mocker.patch("common.core.utils.connections", {"default": {}})
196+
manager = get_user_model().objects
197+
198+
# When / Then
199+
with django_assert_num_queries(1, using="default"):
200+
using_database_replica(manager).first()
201+
202+
203+
@pytest.mark.django_db(databases="__all__")
204+
def test_maybe_use_replicas__with_replicas__sequential_strategy__picks_databases_sequentially(
205+
django_assert_num_queries: DjangoAssertNumQueries,
206+
settings: SettingsWrapper,
207+
) -> None:
208+
# Given
209+
settings.REPLICA_READ_STRATEGY = "sequential"
210+
manager = get_user_model().objects
211+
212+
# When / Then
213+
with django_assert_num_queries(1, using="replica_1"):
214+
using_database_replica(manager).first()
215+
with django_assert_num_queries(1, using="replica_2"):
216+
using_database_replica(manager).first()
217+
with django_assert_num_queries(1, using="replica_3"):
218+
using_database_replica(manager).first()
219+
with django_assert_num_queries(1, using="replica_1"):
220+
using_database_replica(manager).first()
221+
222+
223+
@pytest.mark.django_db(databases="__all__")
224+
def test_maybe_use_replicas__with_replicas__distributed_strategy__picks_databases_randomly(
225+
django_assert_max_num_queries: DjangoAssertNumQueries,
226+
settings: SettingsWrapper,
227+
) -> None:
228+
# Given
229+
settings.REPLICA_READ_STRATEGY = "distributed"
230+
manager = get_user_model().objects
231+
232+
# When / Then
233+
with django_assert_max_num_queries(10, using="replica_1") as captured:
234+
using_database_replica(manager).first()
235+
using_database_replica(manager).first()
236+
using_database_replica(manager).first()
237+
using_database_replica(manager).first()
238+
using_database_replica(manager).first()
239+
using_database_replica(manager).first()
240+
using_database_replica(manager).first()
241+
using_database_replica(manager).first()
242+
using_database_replica(manager).first()
243+
using_database_replica(manager).first()
244+
assert (captured.final_queries or 0) >= 1
245+
with django_assert_max_num_queries(10, using="replica_2") as captured:
246+
using_database_replica(manager).first()
247+
using_database_replica(manager).first()
248+
using_database_replica(manager).first()
249+
using_database_replica(manager).first()
250+
using_database_replica(manager).first()
251+
using_database_replica(manager).first()
252+
using_database_replica(manager).first()
253+
using_database_replica(manager).first()
254+
using_database_replica(manager).first()
255+
using_database_replica(manager).first()
256+
assert (captured.final_queries or 0) >= 1
257+
with django_assert_max_num_queries(10, using="replica_3") as captured:
258+
using_database_replica(manager).first()
259+
using_database_replica(manager).first()
260+
using_database_replica(manager).first()
261+
using_database_replica(manager).first()
262+
using_database_replica(manager).first()
263+
using_database_replica(manager).first()
264+
using_database_replica(manager).first()
265+
using_database_replica(manager).first()
266+
using_database_replica(manager).first()
267+
using_database_replica(manager).first()
268+
assert (captured.final_queries or 0) >= 1

0 commit comments

Comments
 (0)