Skip to content

Commit c84d060

Browse files
Don't require exclusive transactions for entire SQLite DB (#132)
Where needed, queries are run in an exclusive transaction. The rest don't need to be, which hopefully improves throughput and prevents lock contention
1 parent 4edc550 commit c84d060

File tree

4 files changed

+16
-45
lines changed

4 files changed

+16
-45
lines changed

django_tasks/backends/database/backend.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22
from dataclasses import dataclass
33
from typing import TYPE_CHECKING, Any, TypeVar
44

5-
import django
65
from django.apps import apps
76
from django.core.checks import messages
87
from django.core.exceptions import ValidationError
9-
from django.db import connections, router, transaction
8+
from django.db import transaction
109
from typing_extensions import ParamSpec
1110

1211
from django_tasks.backends.base import BaseTaskBackend
@@ -81,9 +80,6 @@ async def aget_result(self, result_id: str) -> TaskResult:
8180
raise ResultDoesNotExist(result_id) from e
8281

8382
def check(self, **kwargs: Any) -> Iterable[messages.CheckMessage]:
84-
from .models import DBTaskResult
85-
from .utils import connection_requires_manual_exclusive_transaction
86-
8783
yield from super().check(**kwargs)
8884

8985
backend_name = self.__class__.__name__
@@ -93,16 +89,3 @@ def check(self, **kwargs: Any) -> Iterable[messages.CheckMessage]:
9389
f"{backend_name} configured as django_tasks backend, but database app not installed",
9490
"Insert 'django_tasks.backends.database' in INSTALLED_APPS",
9591
)
96-
97-
db_connection = connections[router.db_for_read(DBTaskResult)]
98-
# Manually called to set `transaction_mode`
99-
db_connection.get_connection_params()
100-
if (
101-
# Versions below 5.1 can't be configured, so always assume exclusive transactions
102-
django.VERSION >= (5, 1)
103-
and connection_requires_manual_exclusive_transaction(db_connection)
104-
):
105-
yield messages.Error(
106-
f"{backend_name} is using SQLite non-exclusive transactions",
107-
f"Set settings.DATABASES[{db_connection.alias!r}]['OPTIONS']['transaction_mode'] to 'EXCLUSIVE'",
108-
)

django_tasks/backends/database/utils.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ def connection_requires_manual_exclusive_transaction(
2222
if django.VERSION < (5, 1):
2323
return True
2424

25+
if not hasattr(connection, "transaction_mode"):
26+
# Manually called to set `transaction_mode`
27+
connection.get_connection_params()
28+
2529
return connection.transaction_mode != "EXCLUSIVE" # type:ignore[attr-defined,no-any-return]
2630

2731

@@ -35,9 +39,6 @@ def exclusive_transaction(using: Optional[str] = None) -> Generator[Any, Any, An
3539
connection: BaseDatabaseWrapper = transaction.get_connection(using)
3640

3741
if connection_requires_manual_exclusive_transaction(connection):
38-
if django.VERSION >= (5, 1):
39-
raise RuntimeError("Transactions must be EXCLUSIVE")
40-
4142
with connection.cursor() as c:
4243
c.execute("BEGIN EXCLUSIVE")
4344
try:

tests/settings.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import sys
33

44
import dj_database_url
5-
import django
65

76
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
87

@@ -59,10 +58,6 @@
5958
)
6059
}
6160

62-
# Set exclusive transactions in 5.1+
63-
if django.VERSION >= (5, 1) and "sqlite" in DATABASES["default"]["ENGINE"]:
64-
DATABASES["default"].setdefault("OPTIONS", {})["transaction_mode"] = "EXCLUSIVE"
65-
6661
if "sqlite" in DATABASES["default"]["ENGINE"]:
6762
DATABASES["default"]["TEST"] = {"NAME": os.path.join(BASE_DIR, "db-test.sqlite3")}
6863

tests/tests/test_database_backend.py

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from django.db import connection, connections, transaction
2020
from django.db.models import QuerySet
2121
from django.db.utils import IntegrityError, OperationalError
22-
from django.test import TestCase, TransactionTestCase, override_settings
22+
from django.test import TransactionTestCase, override_settings
2323
from django.test.testcases import _deferredSkip # type:ignore[attr-defined]
2424
from django.urls import reverse
2525
from django.utils import timezone
@@ -264,23 +264,6 @@ def test_database_backend_app_missing(self) -> None:
264264
self.assertEqual(len(errors), 1)
265265
self.assertIn("django_tasks.backends.database", errors[0].hint) # type:ignore[arg-type]
266266

267-
@skipIf(
268-
connection.vendor != "sqlite", "Transaction mode is only applicable on SQLite"
269-
)
270-
@skipIf(django.VERSION < (5, 1), "Manual transaction check only runs on 5.1+")
271-
def test_check_non_exclusive_transaction(self) -> None:
272-
try:
273-
with mock.patch.dict(
274-
connection.settings_dict["OPTIONS"], {"transaction_mode": None}
275-
):
276-
errors = list(default_task_backend.check())
277-
278-
self.assertEqual(len(errors), 1)
279-
self.assertIn("['transaction_mode'] to 'EXCLUSIVE'", errors[0].hint) # type:ignore[arg-type]
280-
finally:
281-
connection.close()
282-
connection.get_connection_params()
283-
284267
def test_priority_range_check(self) -> None:
285268
with self.assertRaises(IntegrityError):
286269
DBTaskResult.objects.create(
@@ -981,12 +964,13 @@ def test_get_locked_with_locked_rows(self) -> None:
981964
new_connection.close()
982965

983966

984-
class ConnectionExclusiveTranscationTestCase(TestCase):
967+
class ConnectionExclusiveTranscationTestCase(TransactionTestCase):
985968
def setUp(self) -> None:
986969
self.connection = connections.create_connection("default")
987970

988971
def tearDown(self) -> None:
989972
self.connection.close()
973+
# connection.close()
990974

991975
@skipIf(connection.vendor == "sqlite", "SQLite handled separately")
992976
def test_non_sqlite(self) -> None:
@@ -1018,6 +1002,14 @@ def test_explicit_transaction(self) -> None:
10181002
connection_requires_manual_exclusive_transaction(self.connection)
10191003
)
10201004

1005+
@skipIf(connection.vendor != "sqlite", "SQLite only")
1006+
def test_exclusive_transaction(self) -> None:
1007+
with self.assertNumQueries(2) as c:
1008+
with exclusive_transaction():
1009+
pass
1010+
1011+
self.assertEqual(c.captured_queries[0]["sql"], "BEGIN EXCLUSIVE")
1012+
10211013

10221014
@override_settings(
10231015
TASKS={

0 commit comments

Comments
 (0)