Skip to content

Commit 336350b

Browse files
authored
Make read/write DB routing consistent for http requests and background tasks (baserow#4077)
Make read/write DB routing consistent for http requests and background tasks
1 parent 2d54f8d commit 336350b

File tree

4 files changed

+154
-16
lines changed

4 files changed

+154
-16
lines changed

backend/pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ markers =
5050
websockets: All tests related to handeling web socket connections
5151
import_export_workspace: All tests related to importing and exporting workspaces
5252
data_sync: All tests related to data sync functionality
53+
replica: All tests related to db replicas
5354
workspace_search: All tests related to workspace search functionality

backend/src/baserow/config/db_routers.py

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,68 @@
11
import random
22

33
from django.conf import settings
4+
from django.db import transaction
45

56
from asgiref.local import Local
67

7-
DATABASE_READ_REPLICAS = settings.DATABASE_READ_REPLICAS
88
DEFAULT_DB_ALIAS = "default"
99

1010
_db_state = Local()
1111

1212

13-
def set_write_mode():
14-
_db_state.pinned = True
13+
def set_db_alias(alias: str) -> str:
14+
"""
15+
Pin the current db connection alias to use for the current request or celery task.
16+
17+
:param alias: The database alias to pin to.
18+
:return: The database alias that was set.
19+
"""
20+
21+
_db_state.alias = alias
22+
return alias
23+
24+
25+
def get_db_alias() -> str | None:
26+
"""
27+
Get the pinned db connection alias for the current request or celery task.
28+
"""
29+
30+
return getattr(_db_state, "alias", None)
31+
32+
33+
def set_db_alias_for_read():
34+
"""
35+
Choose a read replica for read queries, unless we are in an atomic block,
36+
in which case we should use the primary database to avoid replication lag issues
37+
or trying to lock data in a read replica.
38+
Once a read replica is chosen, it is pinned for the duration of the request or
39+
celery task.
40+
41+
:return: The database alias to use for read queries.
42+
"""
43+
44+
# Make sure LOCK always happen on the DEFAULT_DB_ALIAS
45+
if transaction.get_connection().in_atomic_block:
46+
return set_db_alias(DEFAULT_DB_ALIAS)
47+
48+
# If we already have an alias set, return it
49+
if (alias := get_db_alias()) is not None:
50+
return alias
1551

52+
# Choose a random read replica if available, otherwise use the default
53+
if settings.DATABASE_READ_REPLICAS:
54+
alias = random.choice(settings.DATABASE_READ_REPLICAS) # nosec
55+
else:
56+
alias = DEFAULT_DB_ALIAS
1657

17-
def is_write_mode():
18-
return getattr(_db_state, "pinned", False)
58+
return set_db_alias(alias)
1959

2060

2161
def clear_db_state():
2262
"""Should be called when a request or celery finishes."""
2363

24-
if hasattr(_db_state, "pinned"):
25-
del _db_state.pinned
64+
if hasattr(_db_state, "alias"):
65+
del _db_state.alias
2666

2767

2868
class ReadReplicaRouter:
@@ -35,20 +75,14 @@ class ReadReplicaRouter:
3575
"""
3676

3777
def db_for_read(self, model, **hints):
38-
if is_write_mode():
39-
return DEFAULT_DB_ALIAS
40-
if DATABASE_READ_REPLICAS:
41-
read = random.choice(DATABASE_READ_REPLICAS) # nosec
42-
return read
43-
return DEFAULT_DB_ALIAS
78+
return set_db_alias_for_read()
4479

4580
def db_for_write(self, model, **hints):
46-
set_write_mode()
47-
return DEFAULT_DB_ALIAS
81+
return set_db_alias(DEFAULT_DB_ALIAS)
4882

4983
def allow_relation(self, obj1, obj2, **hints):
5084
db_set = {DEFAULT_DB_ALIAS}
51-
db_set.update(DATABASE_READ_REPLICAS)
85+
db_set.update(settings.DATABASE_READ_REPLICAS)
5286
if obj1._state.db in db_set and obj2._state.db in db_set:
5387
return True
5488
return None
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
from django.test.utils import override_settings
4+
5+
import pytest
6+
7+
from baserow.config.db_routers import (
8+
DEFAULT_DB_ALIAS,
9+
ReadReplicaRouter,
10+
clear_db_state,
11+
get_db_alias,
12+
)
13+
14+
15+
@pytest.mark.replica
16+
@pytest.mark.django_db
17+
@override_settings(DATABASE_READ_REPLICAS=["replica1", "replica2"])
18+
def test_router_uses_replica_outside_transaction_and_sticks():
19+
router = ReadReplicaRouter()
20+
clear_db_state()
21+
22+
mock_conn = MagicMock()
23+
mock_conn.get_autocommit.return_value = True
24+
mock_conn.in_atomic_block = False
25+
26+
with patch("django.db.transaction.get_connection", return_value=mock_conn):
27+
# Outside transaction should use replica and stick to it
28+
alias1 = router.db_for_read(model=None)
29+
assert alias1 in ["replica1", "replica2"]
30+
assert get_db_alias() == alias1
31+
32+
alias2 = router.db_for_read(model=None)
33+
assert alias2 == alias1
34+
35+
36+
@pytest.mark.replica
37+
@pytest.mark.django_db
38+
@override_settings(DATABASE_READ_REPLICAS=["replica1", "replica2"])
39+
def test_router_switches_to_default_inside_transaction():
40+
router = ReadReplicaRouter()
41+
clear_db_state()
42+
43+
# Mock connection for outside transaction
44+
mock_conn_outside = MagicMock()
45+
mock_conn_outside.get_autocommit.return_value = True
46+
mock_conn_outside.in_atomic_block = False
47+
48+
with patch("django.db.transaction.get_connection", return_value=mock_conn_outside):
49+
# Start outside transaction - should use replica
50+
alias_outside = router.db_for_read(model=None)
51+
assert alias_outside in ["replica1", "replica2"]
52+
53+
# Mock connection for inside transaction
54+
mock_conn_inside = MagicMock()
55+
mock_conn_inside.get_autocommit.return_value = False
56+
mock_conn_inside.in_atomic_block = True
57+
58+
with patch("django.db.transaction.get_connection", return_value=mock_conn_inside):
59+
# Enter transaction - should switch to default and stick
60+
alias1 = router.db_for_read(model=None)
61+
assert alias1 == DEFAULT_DB_ALIAS
62+
63+
alias2 = router.db_for_read(model=None)
64+
assert alias2 == DEFAULT_DB_ALIAS
65+
66+
# After transaction, should still be default (sticky)
67+
with patch("django.db.transaction.get_connection", return_value=mock_conn_outside):
68+
alias3 = router.db_for_read(model=None)
69+
assert alias3 == DEFAULT_DB_ALIAS
70+
71+
72+
@pytest.mark.replica
73+
@pytest.mark.django_db
74+
@override_settings(DATABASE_READ_REPLICAS=["replica1", "replica2"])
75+
def test_write_switches_to_default_and_sticks():
76+
router = ReadReplicaRouter()
77+
clear_db_state()
78+
79+
# Mock connection for outside transaction
80+
mock_conn = MagicMock()
81+
mock_conn.get_autocommit.return_value = True
82+
mock_conn.in_atomic_block = False
83+
84+
with patch("django.db.transaction.get_connection", return_value=mock_conn):
85+
# Start outside transaction - should use replica
86+
alias_before_write = router.db_for_read(model=None)
87+
assert alias_before_write in ["replica1", "replica2"]
88+
89+
# Write should switch to default
90+
write_alias = router.db_for_write(model=None)
91+
assert write_alias == DEFAULT_DB_ALIAS
92+
93+
# Read after write should still be default
94+
read_after_write = router.db_for_read(model=None)
95+
assert read_after_write == DEFAULT_DB_ALIAS
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"type": "refactor",
3+
"message": "Make read/write DB routing consistent for http requests and background tasks",
4+
"domain": "database",
5+
"issue_number": 3848,
6+
"bullet_points": [],
7+
"created_at": "2025-10-06"
8+
}

0 commit comments

Comments
 (0)