Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ jobs:
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
django-version: ["4.2", "5.0"]
django-version: ["4.2", "5.0", "5.1"]
exclude:
- django-version: "5.0"
python-version: "3.8"
- django-version: "5.0"
python-version: "3.9"
- django-version: "5.1"
python-version: "3.8"
- django-version: "5.1"
python-version: "3.9"
- os: windows-latest # JSON1 is only built-in on 3.9+
python-version: 3.8

Expand Down Expand Up @@ -60,7 +64,7 @@ jobs:
strategy:
fail-fast: false
matrix:
django-version: ["4.2", "5.0"]
django-version: ["4.2", "5.0", "5.1"]
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
Expand Down Expand Up @@ -95,7 +99,7 @@ jobs:
strategy:
fail-fast: false
matrix:
django-version: ["4.2", "5.0"]
django-version: ["4.2", "5.0", "5.1"]
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
Expand Down
10 changes: 7 additions & 3 deletions django_tasks/backends/database/backend.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Iterable, TypeVar

import django
from django.apps import apps
from django.core.checks import ERROR, CheckMessage
from django.core.exceptions import ValidationError
Expand Down Expand Up @@ -84,6 +85,7 @@ async def aget_result(self, result_id: str) -> TaskResult:

def check(self, **kwargs: Any) -> Iterable[CheckMessage]:
from .models import DBTaskResult
from .utils import connection_requires_manual_exclusive_transaction

backend_name = self.__class__.__name__

Expand All @@ -95,10 +97,12 @@ def check(self, **kwargs: Any) -> Iterable[CheckMessage]:
)

db_connection = connections[router.db_for_read(DBTaskResult)]
# Manually called to set `transaction_mode`
db_connection.get_connection_params()
if (
db_connection.vendor == "sqlite"
and hasattr(db_connection, "transaction_mode")
and db_connection.transaction_mode != "EXCLUSIVE"
# Versions below 5.1 can't be configured, so always assume exclusive transactions
django.VERSION >= (5, 1)
and connection_requires_manual_exclusive_transaction(db_connection)
):
yield CheckMessage(
ERROR,
Expand Down
29 changes: 24 additions & 5 deletions django_tasks/backends/database/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,26 @@
from typing import Any, Generator, Optional, Union
from uuid import UUID

import django
from django.db import transaction
from django.db.backends.base.base import BaseDatabaseWrapper


def connection_requires_manual_exclusive_transaction(
connection: BaseDatabaseWrapper,
) -> bool:
"""
Determine whether the backend requires manual transaction handling.

Extracted from `exclusive_transaction` for unit testing purposes.
"""
if connection.vendor != "sqlite":
return False

if django.VERSION < (5, 1):
return True

return connection.transaction_mode != "EXCLUSIVE" # type:ignore[attr-defined,no-any-return]


@contextmanager
Expand All @@ -12,12 +31,12 @@ def exclusive_transaction(using: Optional[str] = None) -> Generator[Any, Any, An

This functionality is built-in to Django 5.1+.
"""
connection = transaction.get_connection(using)
connection: BaseDatabaseWrapper = transaction.get_connection(using)

if connection_requires_manual_exclusive_transaction(connection):
if django.VERSION >= (5, 1):
raise RuntimeError("Transactions must be EXCLUSIVE")

if (
connection.vendor == "sqlite"
and getattr(connection, "transaction_mode", None) != "EXCLUSIVE"
):
with connection.cursor() as c:
c.execute("BEGIN EXCLUSIVE")
try:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ classifiers = [
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Natural Language :: English",
Expand Down
4 changes: 4 additions & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sys

import dj_database_url
import django

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

Expand Down Expand Up @@ -60,6 +61,9 @@
)
}

# Set exclusive transactions in 5.1+
if django.VERSION >= (5, 1) and "sqlite" in DATABASES["default"]["ENGINE"]:
DATABASES["default"].setdefault("OPTIONS", {})["transaction_mode"] = "EXCLUSIVE"

USE_TZ = True

Expand Down
51 changes: 47 additions & 4 deletions tests/tests/test_database_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from typing import Sequence, Union, cast
from unittest import skipIf

import django
from django.core.exceptions import SuspiciousOperation
from django.core.management import call_command, execute_from_command_line
from django.db import connection, connections, transaction
from django.db.models import QuerySet
from django.db.utils import IntegrityError, OperationalError
from django.test import TransactionTestCase, override_settings
from django.test import TestCase, TransactionTestCase, override_settings
from django.urls import reverse
from django.utils import timezone

Expand All @@ -22,7 +23,11 @@
logger as db_worker_logger,
)
from django_tasks.backends.database.models import DBTaskResult
from django_tasks.backends.database.utils import exclusive_transaction, normalize_uuid
from django_tasks.backends.database.utils import (
connection_requires_manual_exclusive_transaction,
exclusive_transaction,
normalize_uuid,
)
from django_tasks.exceptions import ResultDoesNotExist
from tests import tasks as test_tasks

Expand Down Expand Up @@ -201,7 +206,7 @@ def test_missing_task_path(self) -> None:
def test_check(self) -> None:
errors = list(default_task_backend.check())

self.assertEqual(len(errors), 0)
self.assertEqual(len(errors), 0, errors)

@override_settings(INSTALLED_APPS=[])
def test_database_backend_app_missing(self) -> None:
Expand Down Expand Up @@ -777,8 +782,46 @@ def test_get_locked_with_locked_rows(self) -> None:
normalize_uuid(result_2.id),
)
self.assertEqual(
normalize_uuid(DBTaskResult.objects.get_locked().id), # type:ignore[union-attr]
normalize_uuid(DBTaskResult.objects.get_locked().id), # type:ignore
normalize_uuid(result_2.id),
)
finally:
new_connection.close()


class ConnectionExclusiveTranscationTestCase(TestCase):
def setUp(self) -> None:
self.connection = connections.create_connection("default")

def tearDown(self) -> None:
self.connection.close()

@skipIf(connection.vendor == "sqlite", "SQLite handled separately")
def test_non_sqlite(self) -> None:
self.assertFalse(
connection_requires_manual_exclusive_transaction(self.connection)
)

@skipIf(
django.VERSION >= (5, 1),
"Newer Django versions support custom transaction modes",
)
@skipIf(connection.vendor != "sqlite", "SQLite only")
def test_old_django_requires_manual_transaction(self) -> None:
self.assertTrue(
connection_requires_manual_exclusive_transaction(self.connection)
)

@skipIf(django.VERSION < (5, 1), "Old Django versions require manual transactions")
@skipIf(connection.vendor != "sqlite", "SQLite only")
def test_explicit_transaction(self) -> None:
# HACK: Set the attribute manually
self.connection.transaction_mode = None # type:ignore[attr-defined]
self.assertTrue(
connection_requires_manual_exclusive_transaction(self.connection)
)

self.connection.transaction_mode = "EXCLUSIVE" # type:ignore[attr-defined]
self.assertFalse(
connection_requires_manual_exclusive_transaction(self.connection)
)