Skip to content

Commit 617861b

Browse files
authored
feat(utils): Allow for explicit use of database replicas (#95)
* Improve database settings - Prevents conflicts with other systems - Less unused code, yay * Allow for explicit use of database replicas * Fall back to cross-region replicas * Add logs * Fix coverage * Fall back to default database if all replicas fail * Improve name * Improve logging * Improve typing * Fix member placing * Fix manager typing
1 parent aeb7520 commit 617861b

File tree

8 files changed

+402
-16
lines changed

8 files changed

+402
-16
lines changed

.github/workflows/python-test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ jobs:
2424
image: postgres:15.5-alpine
2525
env:
2626
POSTGRES_HOST_AUTH_METHOD: trust
27-
ports: ['5432:5432']
27+
ports: ["6543:5432"]
2828
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
2929

3030
task-processor-database:
3131
image: postgres:15.5-alpine
3232
env:
3333
POSTGRES_HOST_AUTH_METHOD: trust
34-
ports: ['5433:5432']
34+
ports: ["6544:5432"]
3535
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
3636

3737
steps:

docker/docker-compose.local.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ services:
1616
volumes:
1717
- default-database:/var/lib/postgresql/data
1818
ports:
19-
- 5432:5432
19+
- 6543:5432
2020
environment:
2121
POSTGRES_HOST_AUTH_METHOD: trust
2222
TZ: UTC
@@ -31,7 +31,7 @@ services:
3131
volumes:
3232
- task-processor-database:/var/lib/postgresql/data
3333
ports:
34-
- 5433:5432
34+
- 6544:5432
3535
environment:
3636
POSTGRES_HOST_AUTH_METHOD: trust
3737
TZ: UTC

settings/dev.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,22 @@
2525
# Settings required for tests
2626
SECRET_KEY = "test"
2727
DATABASES = {
28-
"default": dj_database_url.parse(
29-
env(
30-
"DATABASE_URL",
31-
default="postgresql://postgres@localhost:5432/postgres",
32-
),
28+
"default": (
29+
default_database_url := dj_database_url.parse(
30+
"postgresql://postgres@localhost:6543/postgres",
31+
)
3332
),
3433
"task_processor": dj_database_url.parse(
35-
env(
36-
"TASK_PROCESSOR_DATABASE_URL",
37-
default="postgresql://postgres@localhost:5433/postgres",
38-
),
34+
"postgresql://postgres@localhost:6544/postgres",
3935
),
36+
# Dummy replicas
37+
"replica_1": default_database_url,
38+
"replica_2": default_database_url,
39+
"replica_3": default_database_url,
40+
"cross_region_replica_1": default_database_url,
41+
"cross_region_replica_2": default_database_url,
4042
}
43+
REPLICA_READ_STRATEGY = "distributed"
4144
TASK_PROCESSOR_DATABASES = ["default"]
4245
INSTALLED_APPS = [
4346
"django.contrib.auth",

src/common/core/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import enum
2+
3+
4+
class ReplicaReadStrategy(enum.StrEnum):
5+
DISTRIBUTED = enum.auto()
6+
SEQUENTIAL = enum.auto()

src/common/core/utils.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
import json
2+
import logging
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 Iterator, Literal, 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
14+
from django.db.utils import OperationalError
15+
16+
from common.core import ReplicaReadStrategy
17+
18+
logger = logging.getLogger(__name__)
1019

1120
UNKNOWN = "unknown"
1221
VERSIONS_INFO_FILE_LOCATION = ".versions.json"
1322

23+
ManagerType = TypeVar("ManagerType", bound=Manager[Model])
24+
25+
ReplicaNamePrefix = Literal["replica_", "cross_region_replica_"]
26+
_replica_sequential_names_by_prefix: dict[ReplicaNamePrefix, Iterator[str]] = {}
27+
1428

1529
class SelfHostedData(TypedDict):
1630
has_users: bool
@@ -114,3 +128,53 @@ def get_file_contents(file_path: str) -> str | None:
114128
return f.read().replace("\n", "")
115129
except FileNotFoundError:
116130
return None
131+
132+
133+
def using_database_replica(
134+
manager: ManagerType,
135+
replica_prefix: ReplicaNamePrefix = "replica_",
136+
) -> ManagerType:
137+
"""Attempts to bind a manager to a healthy database replica"""
138+
local_replicas = [name for name in connections if name.startswith(replica_prefix)]
139+
140+
if not local_replicas:
141+
logger.info("No replicas set up.")
142+
return manager
143+
144+
chosen_replica = None
145+
146+
if settings.REPLICA_READ_STRATEGY == ReplicaReadStrategy.SEQUENTIAL:
147+
sequence = _replica_sequential_names_by_prefix.setdefault(
148+
replica_prefix, cycle(local_replicas)
149+
)
150+
for _ in range(len(local_replicas)):
151+
attempted_replica = next(sequence)
152+
try:
153+
connections[attempted_replica].ensure_connection()
154+
chosen_replica = attempted_replica
155+
break
156+
except OperationalError:
157+
logger.exception(f"Replica '{attempted_replica}' is not available.")
158+
continue
159+
160+
if settings.REPLICA_READ_STRATEGY == ReplicaReadStrategy.DISTRIBUTED:
161+
for _ in range(len(local_replicas)):
162+
attempted_replica = random.choice(local_replicas)
163+
try:
164+
connections[attempted_replica].ensure_connection()
165+
chosen_replica = attempted_replica
166+
break
167+
except OperationalError:
168+
logger.exception(f"Replica '{attempted_replica}' is not available.")
169+
local_replicas.remove(attempted_replica)
170+
continue
171+
172+
if not chosen_replica:
173+
if replica_prefix == "replica_":
174+
logger.warning("Falling back to cross-region replicas, if any.")
175+
return using_database_replica(manager, "cross_region_replica_")
176+
177+
logger.warning("No replicas available.")
178+
return manager
179+
180+
return manager.db_manager(chosen_replica)

tests/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from typing import Callable
2+
3+
GetLogsFixture = Callable[[str], list[tuple[str, str]]]

tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@
55
import prometheus_client
66
import pytest
77

8+
from tests import GetLogsFixture
9+
810
pytest_plugins = "flagsmith-test-tools"
911

1012

13+
@pytest.fixture()
14+
def get_logs(caplog: pytest.LogCaptureFixture) -> GetLogsFixture:
15+
caplog.set_level("DEBUG")
16+
17+
def _logs(module: str) -> list[tuple[str, str]]:
18+
return [(r.levelname, r.message) for r in caplog.records if r.name == module]
19+
20+
return _logs
21+
22+
1123
@pytest.fixture()
1224
def prometheus_multiproc_dir(
1325
tmp_path_factory: pytest.TempPathFactory,

0 commit comments

Comments
 (0)