Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added

- Search plugin for MkDocs
- Raise an error when entering `db.transaction()` (and `transaction_if_not_already()` when it opens) **if** the testcase transaction already has pending `on_commit` callbacks.
Opt-out via `SUBATOMIC_RAISE_IF_PENDING_TESTCASE_ON_COMMIT_ON_ENTER` (default: `True`). (#31)

### Fixed

Expand Down
10 changes: 10 additions & 0 deletions docs/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ on a per-test basis.

[override_settings]: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.override_settings
[django-settings]: https://docs.djangoproject.com/en/stable/topics/settings/


## `SUBATOMIC_RAISE_IF_PENDING_TESTCASE_ON_COMMIT_ON_ENTER`

**Default:** `True`

When `True`, entering `db.transaction()` (and `transaction_if_not_already()` when it opens) **raises** if the test suite’s transaction already has pending `on_commit` callbacks.
This avoids accidentally flushing callbacks that were queued **before** opening an application transaction in tests.

Set to `False` to keep the previous behaviour during migration.
59 changes: 48 additions & 11 deletions src/django_subatomic/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,39 @@
"transaction_required",
]

@attrs.frozen
class _PendingTestcaseAfterCommitCallbacks(Exception):
pending: int
database: str | None = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
database: str | None = None
database: str


def __str__(self) -> str:
return (
f"{self.pending} pending on_commit() callback(s) already queued on the "
f"test-case transaction (db={self.database!r}). "
"Register on_commit() inside a real transaction or set "
"SUBATOMIC_RAISE_IF_PENDING_TESTCASE_ON_COMMIT_ON_ENTER=False to opt out."
)


@contextlib.contextmanager
def _error_if_pending_testcase_on_commit_callbacks(*, using: str | None = None):
if not getattr(
settings,
"SUBATOMIC_RAISE_IF_PENDING_TESTCASE_ON_COMMIT_ON_ENTER",
True, # strict par défaut
):
yield
return

if _innermost_atomic_block_wraps_testcase(using=using):
connection = django_transaction.get_connection(using=using)
pending = getattr(connection, "run_on_commit", None)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

connection should always have a run_on_commit attribute.

Suggested change
pending = getattr(connection, "run_on_commit", None)
pending = connection.run_on_commit

if pending: # non vide => des callbacks ont été enregistrés avant
raise _PendingTestcaseAfterCommitCallbacks(
pending=len(pending),
database=getattr(connection, "alias", None),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A connection will always have alias set.

Suggested change
database=getattr(connection, "alias", None),
database=connection.alias,

)
yield

@contextlib.contextmanager
def transaction(*, using: str | None = None) -> Generator[None]:
Expand All @@ -43,11 +76,11 @@ def transaction(*, using: str | None = None) -> Generator[None]:
"""
# Note that `savepoint=False` is not required here because
# the `savepoint` flag is ignored when `durable` is `True`.
with (
_execute_on_commit_callbacks_in_tests(using),
django_transaction.atomic(using=using, durable=True),
):
yield
with _error_if_pending_testcase_on_commit_callbacks(using=using):
with _execute_on_commit_callbacks_in_tests(using), django_transaction.atomic(
using=using, durable=True
):
yield
Comment on lines +79 to +83
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We prefer this style for nested context managers:

Suggested change
with _error_if_pending_testcase_on_commit_callbacks(using=using):
with _execute_on_commit_callbacks_in_tests(using), django_transaction.atomic(
using=using, durable=True
):
yield
with (
_error_if_pending_testcase_on_commit_callbacks(using=using),
_execute_on_commit_callbacks_in_tests(using),
django_transaction.atomic(using=using, durable=True),
):
yield



@contextlib.contextmanager
Expand Down Expand Up @@ -81,13 +114,17 @@ def transaction_if_not_already(*, using: str | None = None) -> Generator[None]:
# If the innermost atomic block is from a test case, we should create a SAVEPOINT here.
# This allows for a rollback when an exception propagates out of this block, and so
# better simulates a production transaction behaviour in tests.
savepoint = _innermost_atomic_block_wraps_testcase(using=using)

with (
_execute_on_commit_callbacks_in_tests(using),
django_transaction.atomic(using=using, savepoint=savepoint),
):
if in_transaction(using=using):
# Already in a transaction; don't open another.
yield
else:
# If the innermost atomic block comes from the testcase, create a SAVEPOINT.
savepoint = _innermost_atomic_block_wraps_testcase(using=using)
with _error_if_pending_testcase_on_commit_callbacks(using=using):
with _execute_on_commit_callbacks_in_tests(using), django_transaction.atomic(
using=using, savepoint=savepoint
):
yield
Comment on lines +117 to +127
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want the in_transaction here:

Suggested change
if in_transaction(using=using):
# Already in a transaction; don't open another.
yield
else:
# If the innermost atomic block comes from the testcase, create a SAVEPOINT.
savepoint = _innermost_atomic_block_wraps_testcase(using=using)
with _error_if_pending_testcase_on_commit_callbacks(using=using):
with _execute_on_commit_callbacks_in_tests(using), django_transaction.atomic(
using=using, savepoint=savepoint
):
yield
savepoint = _innermost_atomic_block_wraps_testcase(using=using)
with (
_error_if_pending_testcase_on_commit_callbacks(using=using),
_execute_on_commit_callbacks_in_tests(using),
django_transaction.atomic(using=using, savepoint=savepoint),
):
yield



@_utils.contextmanager
Expand Down
39 changes: 39 additions & 0 deletions tests/test_on_commit_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# tests/test_on_commit_guard.py

from django.test import TestCase, override_settings
from django.db import transaction as dj_tx

from django_subatomic import db
from django_subatomic.db import _PendingTestcaseAfterCommitCallbacks


class TestOnCommitGuardWithTransaction(TestCase):
def test_raises_on_enter_if_testcase_has_pending_callbacks(self):
dj_tx.on_commit(lambda: None)
with self.assertRaises(_PendingTestcaseAfterCommitCallbacks):
with db.transaction():
pass
Comment on lines +12 to +15
Copy link
Collaborator

@LilyFirefly LilyFirefly Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to assert that the on_commit callback also doesn't run in this case:

Suggested change
dj_tx.on_commit(lambda: None)
with self.assertRaises(_PendingTestcaseAfterCommitCallbacks):
with db.transaction():
pass
calls = []
dj_tx.on_commit(lambda: calls.append("ok"))
with self.assertRaises(_PendingTestcaseAfterCommitCallbacks):
with db.transaction():
pass
self.assertEqual(calls, [])


@override_settings(SUBATOMIC_RAISE_IF_PENDING_TESTCASE_ON_COMMIT_ON_ENTER=False)
def test_opt_out_keeps_previous_behaviour(self):
calls = []
dj_tx.on_commit(lambda: calls.append("ok"))
with db.transaction():
self.assertEqual(calls, [])
self.assertEqual(calls, ["ok"])


class TestOnCommitGuardWithTransactionIfNotAlready(TestCase):
def test_raises_on_enter_in_transaction_if_not_already(self):
dj_tx.on_commit(lambda: None)
with self.assertRaises(_PendingTestcaseAfterCommitCallbacks):
with db.transaction_if_not_already():
pass
Comment on lines +28 to +31
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
dj_tx.on_commit(lambda: None)
with self.assertRaises(_PendingTestcaseAfterCommitCallbacks):
with db.transaction_if_not_already():
pass
calls = []
dj_tx.on_commit(lambda: calls.append("ok"))
with self.assertRaises(_PendingTestcaseAfterCommitCallbacks):
with db.transaction_if_not_already():
pass
self.assertEqual(calls, [])


@override_settings(SUBATOMIC_RAISE_IF_PENDING_TESTCASE_ON_COMMIT_ON_ENTER=False)
def test_opt_out_keeps_previous_behaviour_in_transaction_if_not_already(self):
calls = []
dj_tx.on_commit(lambda: calls.append("ok"))
with db.transaction_if_not_already():
self.assertEqual(calls, [])
self.assertEqual(calls, ["ok"])
Loading