Skip to content

Commit 74dcf4c

Browse files
authored
Merge pull request #157 from kraken-tech/meshy/ambiguous-after-commit-execution
Ensure after-commit behaviour is explicit in tests
2 parents 8d2290d + 7551249 commit 74dcf4c

5 files changed

Lines changed: 175 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1818
- `part_of_a_transaction` now raises an error if unhandled callbacks are detected when it starts.
1919
This makes it more similar to `transaction`.
2020
The error can be silenced by setting the `SUBATOMIC_CATCH_UNHANDLED_AFTER_COMMIT_CALLBACKS_IN_TESTS` setting to `False`
21+
- `run_after_commit` now raises an error in tests if it doesn't know if callbacks should be run.
22+
This prevents tests from silently doing the wrong thing (i.e. not running the callbacks).
23+
To fix this error, use `transaction` instead of `atomic`
24+
(or to explicitly disable callbacks when testing low-level code use `part_of_a_transaction`).
25+
The error can be silenced by setting `SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS` setting to `False`.
2126

2227
### Fixed
2328
- Disallowed nesting of `part_of_a_transaction` to prevent nonsense

docs/reference/settings.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,33 @@ transition to this strict behaviour
1515
by getting it working in tests
1616
before enabling it in production.
1717

18+
## `SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS`
19+
20+
(default: `True`)
21+
22+
When this setting is `True`,
23+
[`run_after_commit`][django_subatomic.db.run_after_commit] will ensure that it knows whether or not after-commit callbacks should be simulated in tests.
24+
To avoid silently doing the wrong thing when it is not sure,
25+
[`run_after_commit`][django_subatomic.db.run_after_commit] will raise `subatomic.db._AmbiguousAfterCommitTestBehaviour`.
26+
(This setting starts with an underscore because it is not intended to be caught and handled.)
27+
28+
This can happen in tests when [`run_after_commit`][django_subatomic.db.run_after_commit] is called
29+
inside a Django `atomic` block,
30+
and that `atomic` is directly nested inside the test suite's transaction.
31+
Because the test suite would roll back the transaction,
32+
after-commit callbacks would not normally be run.
33+
34+
Where after-commit callbacks should be run,
35+
this can be fixed by replacing (or wrapping) the `atomic` block with
36+
[`subatomic.db.transaction`][django_subatomic.db.transaction]
37+
(or [`transaction_if_not_already`][django_subatomic.db.transaction_if_not_already] if necessary).
38+
39+
In tests where after-commit callbacks should not be run,
40+
[`part_of_a_transaction`][django_subatomic.test.part_of_a_transaction] should be used instead.
41+
42+
If this setting is `False`,
43+
the after-commit callbacks that this check would catch will not be run.
44+
1845
## `SUBATOMIC_RUN_AFTER_COMMIT_CALLBACKS_IN_TESTS`
1946

2047
(default: `True`)

src/django_subatomic/db.py

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ def _transaction(*, using: str | None) -> Generator[None]:
6363
# the `savepoint` flag is ignored when `durable` is `True`.
6464
django_transaction.atomic(using=using, durable=True),
6565
):
66+
connection = django_transaction.get_connection(using=using)
67+
atomic_block = connection.atomic_blocks[-1]
68+
atomic_block._from_subatomic = True # noqa: SLF001
6669
yield
6770

6871
decorator = _transaction(using=using)
@@ -325,6 +328,37 @@ class _MissingRequiredTransaction(Exception):
325328
database: str
326329

327330

331+
@attrs.frozen
332+
class _AmbiguousAfterCommitTestBehaviour(Exception):
333+
"""
334+
Raised in tests when it's unclear if after-commit callbacks should be run.
335+
336+
You are calling `run_after_commit` inside an `atomic` block in a test.
337+
This `atomic` is inside the test suite's transaction,
338+
so after-commit callbacks will not be run
339+
(because the test suite will roll back the transaction).
340+
341+
Subatomic doesn't know if you intended for these callbacks to be run or not,
342+
so raises this error to avoid silently doing the wrong thing.
343+
344+
In production code, or tests where after-commit callbacks should be run,
345+
replace (or wrap) the `atomic` block with `subatomic.db.transaction`.
346+
347+
In tests where after-commit callbacks should not be run,
348+
use `subatomic.test.part_of_a_transaction` instead.
349+
350+
To help your project progressively adopt this check,
351+
you can disable this requirement for after-commit callbacks by setting
352+
`settings.SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS` to `False`.
353+
354+
See Note [_MissingRequiredTransaction in tests]
355+
356+
This exception should not be caught, as it indicates a programming error.
357+
"""
358+
359+
database: str
360+
361+
328362
@attrs.frozen
329363
class _UnexpectedOpenTransaction(Exception):
330364
"""
@@ -424,18 +458,9 @@ def run_after_commit(
424458
if using is None:
425459
using = django_db.DEFAULT_DB_ALIAS
426460

427-
# See Note [After-commit callbacks require a transaction]
428-
needs_transaction = getattr(
429-
settings, "SUBATOMIC_AFTER_COMMIT_NEEDS_TRANSACTION", True
430-
)
431-
only_in_testcase_transaction = _innermost_atomic_block_wraps_testcase(using=using)
432-
433-
# Fail if a transaction is required, but none exists.
434-
# Ignore test-suite transactions when checking for a transaction.
435-
# See Note [After-commit callbacks require a transaction]
436-
if needs_transaction and not in_transaction(using=using):
437-
raise _MissingRequiredTransaction(database=using)
461+
_ensure_transaction_is_open(using=using)
438462

463+
only_in_testcase_transaction = _innermost_atomic_block_wraps_testcase(using=using)
439464
if (
440465
# See Note [Running after-commit callbacks in tests]
441466
getattr(settings, "SUBATOMIC_RUN_AFTER_COMMIT_CALLBACKS_IN_TESTS", True)
@@ -446,6 +471,54 @@ def run_after_commit(
446471
django_transaction.on_commit(callback, using=using)
447472

448473

474+
def _ensure_transaction_is_open(*, using: str) -> None:
475+
"""
476+
Raise an error if transactions are required but missing.
477+
478+
If there is no transaction open, `_MissingRequiredTransaction` is raised.
479+
This can be silenced with the Django setting
480+
`SUBATOMIC_AFTER_COMMIT_NEEDS_TRANSACTION`.
481+
482+
See Note [After-commit callbacks require a transaction]
483+
484+
When transactions are managed by the test suite,
485+
this also ensures after-commit emulation is accounted for by Subatomic.
486+
When they are not, `_AmbiguousAfterCommitTestBehaviour` is raised.
487+
This can be silenced with the Django setting
488+
`SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS`.
489+
"""
490+
needs_transaction = getattr(
491+
settings, "SUBATOMIC_AFTER_COMMIT_NEEDS_TRANSACTION", True
492+
)
493+
# Skip checks if they have been disabled.
494+
if not needs_transaction:
495+
return
496+
497+
# Fail if we're not in a transaction.
498+
if not in_transaction(using=using):
499+
raise _MissingRequiredTransaction(database=using)
500+
501+
ambiguity_error_in_tests = getattr(
502+
settings, "SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS", True
503+
)
504+
if not ambiguity_error_in_tests:
505+
return
506+
507+
connection = django_transaction.get_connection(using=using)
508+
509+
# We expect after-commit callbacks to be handled by Django
510+
# if we're not in a test-managed transaction.
511+
if not connection.atomic_blocks[0]._from_testcase: # noqa: SLF001
512+
return
513+
514+
# `_from_testcase` told us that we're in a test-managed transaction.
515+
# `in_transaction` told us that we're in a further atomic context.
516+
# If Subatomic didn't open that context then we don't know if the
517+
# test expects after-commit callbacks to be emulated or not.
518+
if not hasattr(connection.atomic_blocks[1], "_from_subatomic"):
519+
raise _AmbiguousAfterCommitTestBehaviour(database=using)
520+
521+
449522
def _innermost_atomic_block_wraps_testcase(*, using: str | None = None) -> bool:
450523
"""
451524
Return True if the current innermost atomic block is wrapping a test case.

src/django_subatomic/test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def part_of_a_transaction(using: str | None = None) -> Generator[None]:
5959
raise _UnhandledCallbacks(tuple(callback for _, callback, _ in callbacks))
6060

6161
with transaction.atomic(using=using, durable=True):
62+
atomic_block = connection.atomic_blocks[-1]
63+
atomic_block._from_subatomic = True # noqa: SLF001
6264
yield
6365

6466
# Throw away any callbacks that were registered during the partial transaction,

tests/test_db.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,11 @@ def test_unhandled_callbacks_cause_error(
226226
counter = Counter()
227227

228228
# Django's `atomic` leaves unhandled after-commit actions on exit.
229-
with django_transaction.atomic():
229+
with (
230+
django_transaction.atomic(),
231+
# This setting prevents `run_after_commit` from raising an error when registering the callback.
232+
override_settings(SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS=False),
233+
):
230234
db.run_after_commit(counter.increment)
231235

232236
# `transaction` will raise when it finds the unhandled callback.
@@ -250,7 +254,11 @@ def test_unhandled_callbacks_check_can_be_disabled(
250254
counter = Counter()
251255

252256
# Django's `atomic` leaves unhandled after-commit actions on exit.
253-
with django_transaction.atomic():
257+
with (
258+
django_transaction.atomic(),
259+
# This setting prevents `run_after_commit` from raising an error when registering the callback.
260+
override_settings(SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS=False),
261+
):
254262
db.run_after_commit(counter.increment)
255263

256264
# Run after-commit callbacks when `transaction` exits,
@@ -587,6 +595,53 @@ def test_outside_transaction_error(self) -> None:
587595
assert exc.value.database == DEFAULT
588596
assert counter.count == 0
589597

598+
def test_ambiguous_after_commit_callback(self) -> None:
599+
"""
600+
`run_after_commit` errors if we're not sure if tests should emulate transactions.
601+
602+
See Note [After-commit callbacks require a transaction]
603+
"""
604+
counter = Counter()
605+
606+
with (
607+
django_transaction.atomic(),
608+
pytest.raises(db._AmbiguousAfterCommitTestBehaviour), # noqa: SLF001
609+
):
610+
db.run_after_commit(counter.increment)
611+
612+
assert counter.count == 0
613+
614+
@pytest.mark.django_db(transaction=True)
615+
def test_unambiguous_unsimulated_callbacks(self) -> None:
616+
"""
617+
`run_after_commit` doesn't error if in `atomic` in a transaction testcase.
618+
619+
This is because Django will handle the after-commit callbacks itself.
620+
"""
621+
counter = Counter()
622+
623+
with django_transaction.atomic():
624+
db.run_after_commit(counter.increment)
625+
626+
assert counter.count == 1
627+
628+
def test_ambiguous_simulation_requirement_disabled(self) -> None:
629+
"""
630+
Callbacks aren't simulated in tests when ambiguous simulation requirement errors are disabled.
631+
632+
See Note [After-commit callbacks require a transaction]
633+
"""
634+
counter = Counter()
635+
636+
with (
637+
django_transaction.atomic(),
638+
override_settings(SUBATOMIC_AFTER_COMMIT_AMBIGUITY_ERROR_IN_TESTS=False),
639+
):
640+
db.run_after_commit(counter.increment)
641+
642+
# The callback was not run.
643+
assert counter.count == 0
644+
590645
@_parametrize_transaction_testcase
591646
def test_transaction_check_can_be_disabled(self) -> None:
592647
"""

0 commit comments

Comments
 (0)